Skip to content

fix(tune-out): tune_out opens a closed channel's transport, and channel_decline refuses a tuned-out channel - #632

Merged
nissa-seru merged 19 commits into
anima-research:mainfrom
nissa-seru:fix/tune-out-opens-transport
Oct 21, 2026
Merged

nissa-seru merged 19 commits into
anima-research:mainfrom
nissa-seru:fix/tune-out-opens-transport

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

tune_out now 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_decline now refuses a tuned-out channel, which it could end without its cancel, losing the backlog.

Problem

  • tune_out diverted 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_decline could 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 main 073f8be1 (Node 24.22.0), with tune_out called through the framework's own dispatch, against the tune-out fixture server made to send a channel's traffic only while it's open:

  • After channel_close, tune_out answered success, no channels/open went out, and a message the server then held back never reached the subconscious.
  • A decline of that tuned-out, closed channel answered success, status: "remained closed", and the cancel after it answered "Nothing was cancelled: no connected channel is tuned out now."
  • With two messages diverted first and the transport then marked not open, the decline and the cancel answered the same, and the two messages never reached the agent.

Changes

  • ChannelRegistry.openForTuneOut(serverId, channelId) makes a tuned-out channel's transport match its tune-out:
    • the work queued for the channel goes first, until none is left, as a delivery's wait for an open channel waits, since each operation reads the decision when it runs, and so opens a channel tuned out meanwhile. That work's own failure, such as a close the server refused, isn't taken as the answer;
    • then the state is read: whether this tune-out is still in effect, by its epoch; the server's state; whether this host directs its channels' lifecycle; and whether the transport reads open: its open flag, unless a close for it failed since it was confirmed, which the registry now records beside its unconfirmed mark;
    • then, where this host directs the lifecycle and the transport doesn't read open, the channel's lifecycle queue opens it once, since applyDesiredChannelState reads 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;
    • no decision is written, so the tune-out stands whatever becomes of the open;
    • a call while that tune-out's open is in flight gets that open's answer, so no second open goes out.
  • 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 new openError field says why the channel's messages don't, or may not, reach the subconscious yet, and when they will:
    • while the server isn't up: they don't, until it's connected and the channel is open;
    • on a server whose channels this host isn't permitted to open: only as that server sends them, calling tune_out again won't change that, and whoever runs this host is the one to ask;
    • after an open that failed: they may not, until this host opens the channel at the server's next registration, if it may open and close its channels then, or now if the agent calls tune_out again;
    • for a channel that isn't registered here: they may not, until this host opens the channel at the server's next registration;
    • where the tune-out ended, or a later decision or a later tune-out replaced it, while its transport was being opened: that it isn't in effect any more.
  • tune_out repeated for a channel already tuned out whose transport doesn't read open, or with lifecycle work queued for the channel, goes through openForTuneOut: 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_out opens a channel the agent closed and answers once that open is done; what openError says; and that, where this host can open the channel, a repeat tries the open again if it isn't open.
  • channel_decline refuses a channel decided tuned out, whatever its transport, before its other checks, in the words channel_open's refusal of a tuned-out channel uses, with the same data. Both refusals now come from one helper.
  • outstandingWorkCarriesOut's doc says what it answers where a push re-makes an entry that a channels/changed removal took while an RPC for the old entry was out. By our reading, none of six servers sends updated for a channel it has removed: discord-mcpl, slack-mcpl, telegram-mcpL, heartbeat-mcpl, portal-mcpl and zulip_mcp.
  • The tune-out fixture server keeps its channel's lifecycle: it records opens and closes, and sends nothing for a channel the host has closed.

What callers notice besides the fixes:

  • tune_out answers 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 repeated tune_out whose transport doesn't read open answers the same way. Each request among them keeps a deadline: the server's requestTimeoutMs, 60 s by default, or 60 s where that's 0, since channels/open and channels/close fall 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 channel y on a server a:
    • "The tune-out is recorded, but its messages don't reach the subconscious until the "a" server is connected and the channel is open; it can't be opened now: " and why the server isn't up. Where this host won't reconnect to it on its own, that ends "; if you need it, ask whoever runs this host."
    • "The tune-out is recorded, but this host isn't permitted to open or close the "a" server's channels, so its messages reach the subconscious only as that server sends them, and calling tune_out again won't change that. If you need this host to open it, ask whoever runs this host."
    • "The tune-out is recorded, but its messages may not reach the subconscious until this host opens the channel, which it does when the "a" server next registers this channel, if this host may open and close its channels then", then, for a failed open, ", or now if you call tune_out again; opening it now didn't complete: …". For a channel that isn't registered here it ends "; it isn't registered here now", and for one whose registration went while the open was out, "; it isn't registered here any more".
    • "The tune-out was recorded, but it isn't in effect any more: it ended, or a later decision about the channel or a later tune-out replaced it, while its transport was being opened."
    • Where openForTuneOut itself throws: "The tune-out is recorded, but its messages may not reach the subconscious, since opening its channel failed: …"
  • tune_out repeated 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's openError text, 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_decline of 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." Its data is { 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

  • openForTuneOut in the registry: the registry owns the channel's lifecycle queue and what it reads (serverReach, directsLifecycle, the queue's tail, the open flag). tune_out calls it from its dispatch, the one caller of TuneOutCoordinator.enter, which stays synchronous.
  • Waiting for queued work first, until none is left: each queued operation reads the decision when it runs, and one whose request is out reads it again when the request is answered, so work already queued opens a channel tuned out meanwhile, and queueing another open would send a second 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, as waitForOpenChannel already 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.
  • Reading the state after each wait, the server's included: a connection can drop, a grant can lapse, and the tune-out can end while the work runs. A dropped connection keeps its entries' open flags, so the flag alone would answer success with no connection to carry anything.
  • The tune-out's epoch: a tune-out cancelled and made again while its open was out is a new tune-out, and the earlier call's answer isn't its.
  • Which open flag reads as open: a failed request leaves the flag as it was, and its outcome is unknown. A close may have taken effect, so after a failed close the tune-out confirms the transport with an open of its own: its success says traffic will arrive, and nothing would show it doesn't. An open, failed or not, can't have closed the channel, so one that fails for the current target leaves an open flag reading open, as when a redundant open queued behind a confirming one fails. (One that fails for a target no longer current withdraws the flag, as before.) So the registry records a close that failed for the current target since its last confirmation, beside its unconfirmed mark, and clears it where the mark is cleared, at a receipt or at traffic. Only the tune-out reads it. A failed open after a failed close leaves the record, so the tune-out sends an open there that may be redundant, never one it needed and skipped. Traffic doesn't clear the record where this host directs the lifecycle, since it doesn't take traffic on an unconfirmed channel as proof, except on an entry a push made that no update has given a target, whose traffic the registry has always taken as proof. The registry's other readers take any flag a failed request left as it was, 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. 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.
  • Waiting again after its own open: work queued after it, such as a registration that came back and queued its reconcile, decides the state the answer reports. Where the last work failed, its failure, the last attempt's, is the reason given, since the answer says why the channel isn't open now and the tune-out's own open may have been for a registration that work replaced.
  • "Don't" and "may not": while the server isn't up, nothing can arrive. After a failed request, the registry treats the outcome as unknown, and an open that took effect would have its traffic diverted, so the answer says "may not". The same holds for a channel that isn't registered here, since a push can make an entry for it.
  • The answer for a channel that isn't registered here: without it, the tune-out's answer would say nothing of why no traffic comes, and tune_out can be called for a channel whose registration goes just after.
  • No decision written: the tune-out is the decision. An open that wrote one would replace it.
  • The repeat retrying the open: TuneOutCoordinator.enter refuses a channel already tuned out, and channel_open refuses 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.
  • Waiting for queued work before answering a repeat at once: the tune-out's own call may still be waiting for work queued after its open, such as a reconcile a channels/changed update 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.
  • The repeat reading the tune-out again after its open: a tune-out that ended or was replaced while the open was out isn't the one the repeat read, so describing it with its settings, or appending what the open did for it, would describe a tune-out that doesn't stand.
  • The refusal saying what the transport is now: a repeat changes nothing about the tune-out, so it stays a refusal, and it says what became of the transport, which the agent asked about by repeating.
  • openForTuneOut being 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.
  • Each wait looking at the queue again in the turn that acts: a wait's last look and the turn it resumes in are apart, and work queued between them would otherwise be missed, its open sent after a reconcile already queued, or its answer given before that work settles. A tail the wait stopped at, as its guard does, isn't waited for again.
  • The deferred answer in tune_out's dispatch: the result goes through pushEvent once 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.
  • openError on 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.
  • Its texts: each says why, and when where that's known, reusing the clauses the registry already gives a decline's close (laterClause, the server's state, the refusal's and the failure's own words). Where a repeat would help, it says so, as channel_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.
  • The decline's refusal before its other checks: a tune-out is the channel's decision whatever its transport, and a decline recorded over it ends it without the cancel that delivers its backlog. tune_out's cancel is what ends one, as channel_open's refusal already says.
  • The same words as channel_open: agents already meet them, and they name the call that helps. One helper gives both, so they can't drift apart.
  • The alreadyTunedOut mark on enter's refusal: the dispatch reads it to tell the already-tuned-out refusal from the coordinator's others, so the two tune-out-messages tests that compare that refusal whole expect it.
  • The fixture's lifecycle: with it, a framework test shows traffic gated by the transport, as a server gates 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.
  • The tests (test/tune-out-transport.test.ts, whose shared REPLACED keeps the ended tune-out's words in one place for the eight tests of an ended or replaced tune-out, and whose heldOpen stub 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):
    • the registry's open: a closed channel opened; an open one left alone; queued work waited for, with one open, the answer still pending while it's out; a close waited for that the server refused, then the transport confirmed with an open; an open waited for that failed, then opened once more; a server that doesn't grant the lifecycle, with the channel closed and open there, one whose grant lapsed while a close was out, and one whose grant lapsed after a close timed out; one that isn't up, with the open flag kept and not, one that won't reconnect, and one that dropped while queued work ran; a channel that isn't registered, one whose registration went while queued work ran, and one whose registration came back then, after a removal and on the same connection, opened by its own reconcile alone; a channels/changed removal 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; tuneOutTransportOpen with 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;
    • a tune-out made while a decline's acknowledgment is out: one open, the tune-out's;
    • channel_decline of a tuned-out channel, open or not: refused, with nothing changed;
    • through the framework: tune_out of 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 as openError, and an open that throws; tune_out repeated 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.
  • Left out: a tune-out restored at boot. Reconciling opens its transport at the server's registration, as before.

Tests

All at this head, 4fa8e54e, which has main 4e3fea38 merged in, on Node 24.22.0.

  • Reproduction on main 073f8be1: the probe described above, through tune_out's own dispatch.
  • Neighbouring tests: test/tune-out-transport.test.ts and 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.
  • Red first. With main's behaviour put back, 48 of the 52 new tests fail. That means tune_out answering 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:
    • the open channel with nothing pending, which gets no request;
    • the two tests of a registration that came back while queued work ran, whose one open is the registration's on main too;
    • a repeat whose transport is open, which gets the refusal as before.
  • Each piece, removed alone, builds and fails its tests:
    • tune_out's answer not waiting for the open: the four framework tests of that answer;
    • no open of its own: the 23 tests that need the transport opened;
    • the check that leaves an open transport alone: the open channel's test, and 21 tests that count the opens sent, one of them by timing out;
    • the failed-close record:
      • left unrecorded: the refused close's test, the timed-out close's on a server without the grant, and the failed close followed by a failed open;
      • recorded for any failed request: the redundant open that fails after a confirming one, and the repeat that joins one waiting for work queued after it;
      • kept past a receipt: the refused close's test and the decline's acknowledgment test;
      • kept past traffic: the timed-out close's test;
    • any flag taken as open where this host directs the lifecycle: the refused close's test and the failed close followed by a failed open; where it doesn't, the timed-out close's test;
    • the first wait for queued work: the nine tests of a close that's out, a refused close, a lapsed grant, a dropped server, a registration that goes, a tune-out made again, a tune-out that ended while that work ran, and a channels/changed removal and addition, and, by timing out, the test of work queued after the wait's last look;
    • the first wait not looking again in the turn that acts: the three tests of a registration that came back or a removal and addition while queued work ran, and the test of work queued after its last look;
    • the wait after the open: the tests of a registration that went and one that came back while the open was out, the repeat joining that wait, and the test of work queued while the awaited work ran;
    • the second wait not looking again: that last test;
    • the server's state:
      • not read: the not-up, dropped-server and overlap tests;
      • read once, before the waits: the dropped-server tests;
      • the grant read before it: the not-up and overlap tests;
      • the remedy for a server that won't reconnect: the not-up test;
    • a server whose channels this host can't open, with the channel open there: the not-permitted tests;
    • the not-permitted answer's last sentences: its five tests;
    • the retry clause in openError: the four tests of a failed open's words;
    • the answer for a channel that isn't registered: its three tests;
    • the tune-out's epoch:
      • not checked: the eight tests of a tune-out that ended, was replaced, was made again or was never recorded;
      • compared by the decision instead: the two tests of a tune-out made again;
      • no guard for a tune-out never recorded: that test;
    • the words for a registration that went, read from the entry: that test;
    • the tune-out's own failure named over the last attempt's: the test of a registration that came back;
    • joining an open in flight: the test of an open asked for again while it's out, which times out;
    • the join kept after a rejection, and openForTuneOut not being async: the test of an open that throws;
    • the repeat:
      • not retrying: the seven framework repeat tests;
      • its transport sentence left out: three of them;
      • its rejection unhandled: the throwing one;
      • describing a replaced tune-out: the two replaced ones;
    • the repeat's reading:
      • ignoring queued work: its own test and the joining repeat's;
      • with no tune-out guard: its own test;
    • the dispatch's catch for an open that throws: that test;
    • the decline's refusal: the registry's decline test and the framework's backlog test;
    • an answer marked as a refusal, which tells a script nothing was done:
      • the repeat's: the seven framework repeat tests;
      • the dispatch's other failures: the repeat whose transport is open;
      • the decline's: the registry's decline test and the framework's backlog test.
  • The whole suite: 6,058 tests, 44 of them skipped, none failing.
  • CI at 4fa8e54e: all ten checks pass: Build & Test on Ubuntu and macOS with Node 20 and 24, the packed release installing on its own, code_execution under Bun 1.4.0 and 1.4.3 on Ubuntu and macOS, and the changelog check.
  • Esther-2584's probes ran beside the test file at each round's head, the last at ddc2d2ce. This head adds two merges of main and three test changes to that. Among them were:
    • a close that timed out;
    • the server dropping, and the grant lapsing, while the tune-out's own open was out;
    • a tune-out made again while its open was out;
    • a repeat behind a queued close;
    • a settled tail that stays current.

Not verified

  • A real server's answer to these opens. The tests use the tune-out fixture server and the registry's fake servers.

What this leaves

Out of scope

Fixes #615.


  • Changelog fragment added: 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_out defect 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 for outstandingWorkCarriesOut'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, which 1ca62d6b and 27d0d17d fix: 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 of 27d0d17d found what 0125e740 and 433d3781 fix. Six runs of our internal review read it: its first version, 1ca62d6b, 9b7eca7f, 36a97908, b5961144 and 55e26dae, and the last three found what b5961144, 55e26dae and ddc2d2ce fix. Tabitha-2467 (Claude Opus 5.5) read both merges of main, and her probe showed the tests couldn't tell a refusal from a failure, which 3e1bf6ed pins.

Description checked and posted by Daniel-2612 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Nigel-2654 and others added 7 commits October 20, 2026 01:06
…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>
@greptile-apps

greptile-apps Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[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.

Findings

  1. P1 Reconnect hides a closed channel ▶
  2. P2 Registration causes an extra open ▶
Fix 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

tune_out now waits for channel work and opens a closed transport without replacing the tune-out. Its answer adds openError when needed. channel_decline now preserves tuned-out channels and their backlog.

  • Tune-outs open closed channels and report when they cannot.
  • Channel declines leave tuned-out channels alone.

Acknowledged scope: nissa-seru deferred the existing channel_close backlog-loss issue to #633 and documented the unchanged remove/push/failed-request limitation in outstandingWorkCarriesOut. The longer wait and dropping results after stop are intentional.

Review used source inspection; no tests were run.

Reviews (1) · Last reviewed commit: "fix(tune-out): take only a confirmed ope..." · Reviewed by Greptile

Comment thread src/mcpl/channel-registry.ts Outdated
Comment on lines +2814 to +2822
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +2781 to +2788
const settle = async (): Promise<unknown> => {
const pending = this.channelLifecycleTails.get(lifecycleKey);
if (!pending) return undefined;
try {
await pending;
return undefined;
} catch (error) {
return error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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, and channel_close is out and will be refused. tune_out is 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, so before is null and 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 reads open: true. This contradicts the PR's own rationale, "queueing another open would send a second channels/open".
  • An answer before the lifecycle work settles. If a channels/changed remove+add arrives during the first wait, the running operation retargets onto the new entry and confirms it. openForTuneOut then 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_close of a tuned-out channel (#633) is the one remaining writer that ends a tune-out without its cancel. We checked every setDesiredState and desiredStates.set call site. The legacy migration and ensureInitialDesiredState write only where no decision exists. openChannelNow writes open over a tune-out only from channel_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_out resolves its channel by bare id (src/framework.ts:26573, the same on main). The first server with that id wins, and there is no serverId, 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 with main 2099c40, which is 38 commits past the merge-base and changes src/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.
  • openForTuneOut writes no decision. Its only effect is applyDesiredChannelState, which reads the decision when it runs. The tune-out's epoch is a fresh randomUUID per enter (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 carrying openError, never an unhandled rejection.
  • channel_decline's refusal runs before every other check, synchronously after the channel is resolved, and its data ({ refusal: 'tuned-out', channelId }) matches channel_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), and MAX_CHANNEL_LIFECYCLE_ATTEMPTS is 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 outstandingWorkCarriesOut doc sentence matches runLifecycleOperation: 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.

Nigel-2654 and others added 7 commits October 21, 2026 15:29
…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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid This round is 0125e740 to e5b1701e: 0125e740 and 433d3781 answer your findings, and the commits after them answer our own reviews of them.

  • R1-1: fixed in 0125e740. Both waits loop until no lifecycle work is left for the channel, as waitForOpenChannel does, and keep the last failure. 433d3781 adds the tests you asked for: a re-registration with handleRegister on the same connection during the first wait, which sends one open, the registration's; and a channels/changed removal and addition during the first wait, with the answer held until the reconcile it queued is done.
  • R1-2: fixed in 433d3781, by your second suggestion. The tune-out takes an open flag as open unless a close for it failed since it was confirmed. The registry records such a close beside its unconfirmed mark, clears it where the mark is cleared, at a receipt or at traffic, and only the tune-out reads it. A failed close still gets an open of the tune-out's own. Tests cover your sequence with the redundant open succeeding and failing, and a failed close followed by a failed open, which still answers "may not reach". We left the first suggestion, skipping a request when the entry is already confirmed in the desired state, alone. An open carrying a backscroll on an open channel must still go out, and the change would reach every reader of the lifecycle, whose redundant opens are main's.
  • R1-3: out of scope, as you suggest, and filed as A channel confirmed open before a reconnect still reads as confirmed open until the server registers it again #644, whose fix is the open fix(mcpl): a closed transport's channel confirmations go with it #665.
  • R1-4: fixed in 433d3781, with b5961144, 55e26dae and ddc2d2ce. tune_out for a channel already tuned out whose transport doesn't read open, or with work queued for the channel, goes through openForTuneOut: it joins that tune-out's open in flight, or waits for the queued work and tries the open again where this host can open the channel. Its refusal then says what the transport is now, or, where the tune-out ended or was replaced meanwhile, only that it isn't in effect any more. A looping agent queues no more open operations than its calls. Each openError a repeat can help now says "or now if you call tune_out again", and the not-permitted one says calling again won't change it and whom to ask.
  • R1-5: tune_out's description now says that a tuned-out channel is kept open, so where this host opens and closes the server's channels, the call opens a channel you closed and answers once that open is done. It says what openError says, and that a repeat tries the open again where it can.
  • R1-6: both tuned-out refusals come from one helper, tunedOutRefusal, with their words unchanged.
  • R1-7: dropped, as you say, since only the fixture could fail it.

— Nigel-2654

Nigel-2654 and others added 4 commits October 21, 2026 16:01
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>
@nissa-seru
nissa-seru merged commit 6afe5e1 into anima-research:main Oct 21, 2026
10 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tune_out records a tune-out of a closed channel without opening its transport, so nothing reaches the subconscious

2 participants