Skip to content

fix(channels): refuse a close of a tuned-out channel, as open and decline do - #681

Merged
nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/close-refuses-tuned-out-channel
Oct 22, 2026
Merged

nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/close-refuses-tuned-out-channel

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

channel_close now refuses a tuned-out channel, as channel_open and channel_decline already do, so a close can no longer end a tune-out without its cancel and lose the backlog it diverted.

Problem

An agent's channel_close of a tuned-out channel recorded closed over the tune-out and answered success. That ended the tune-out without its cancel: the messages it had diverted never reached the agent, and tune_out mode "cancel" afterwards answered "Nothing was cancelled: no connected channel is tuned out now." channel_open and channel_decline already refuse a tuned-out channel for this reason, and the decline's comment names the harm: a decision recorded over the tune-out "would end it without its cancel".

Reproduced on main f44fc407 (Node 24.22.0), through the framework's own dispatch against the tune-out fixture server:

  • tune_out answered success, and two messages were diverted;
  • channel_close answered {"channelId":"disc:guild:noisy","status":"closed"}, after which the channel's decision was closed and its tune-out state gone;
  • the cancel then answered "Nothing was cancelled…", and after 3 s neither diverted message had reached the agent.

A module's close that certifies an idle lease (overrideExplicitOpen) passes the housekeeping guard, which refuses a machine close of an agent's tune-out only without the lease, and did the same.

!Fixes #633.

Changes

  • handleToolClose refuses a channel decided tuned out, with the refusal channel_open and channel_decline give (tunedOutRefusal, with the tool's name): it says the channel is tuned out, that channel_close doesn't change that, and to call tune_out mode "cancel", which delivers the diverted backlog, after which channel_close closes it. Nothing changes: no decision is written and nothing goes to the server.
  • A module's close, such as one that certifies an idle lease, is refused as a stand-down, with the same words and data (refusal: 'tuned-out') and isError: false, the form of the housekeeping guard's own refusal, so a module stands down rather than retrying.
  • The check comes after the housekeeping guard, so a machine close without the lease keeps the guard's explicit-open refusal, as before. Only the closes the guard lets through meet the new one: the agent's own, a module's that certifies an idle lease, and a module's with no machine source, which is recorded as the agent's.
  • A tune-out on record where the host has no tune-out coordinator ends when the channel subsystem comes up, and its channel is decided open again, as a cancel leaves it. A start with no subconscious makes no coordinator, and neither does a start with no servers that connects its first one later. On such a host nothing diverts the channel's traffic or can cancel the tune-out, and tune_out isn't offered. channel_open and channel_decline already refused the channel, and the close was the one way out, which this change takes away.
    • The ending keeps the tune-out's own source, so an agent's tune-out stays the agent's decision, which the housekeeping guard reads.
    • A log line names the channel and says how many of the messages the tune-out diverted are held, stored or still queued to be written, which nothing will deliver as its backlog.
    • If recording the ending fails, the line says so, and the tune-out stays recorded until a start that can end it.
  • Two answers that sent the agent to channel_close over a tuned-out channel now send it to the cancel:
    • a close that a tune-out overtook answered "…it was tuned out meanwhile, so this close doesn't stand. Call channel_close again to close it." It now answers in the tuned-out refusal's words, as an open that a tune-out overtook already answers in its own. One helper, tunedOutPointer, builds the cancel sentence for both close answers;
    • a decline that a tune-out overtook while its acknowledgment was out ended its closeError with "If you still want it closed, call channel_close." It now says to cancel the tune-out with tune_out mode "cancel", which delivers what it diverted, and then call channel_close.

What each part is for, and what was left out

  • The check is the one place a close can record closed over a tune-out: the already-closed branch needs closed on record, the decline refuses a tuned-out channel, and the registry's other writers record open, a default, or put back a decision an open replaced.
  • isError: false for a module keeps the guard's convention for refusals a module should stand down on.
  • The two overtaken answers named channel_close to an agent whose channel a tune-out had overtaken.
  • The ending where there's no coordinator is the state the refusal would otherwise strand. It reads the decisions on record (tuneOutsOnRecord) rather than the registered channels, since a server registers its channels after the replay, or not at all. Its log line's count is what tells an operator what the ending leaves undelivered, and it scans the primary's messages once per such tune-out, only then. A failure to record the ending is logged and left, since that start has no way to retry it.
  • The tests: each new test fails without the piece it covers (see Tests). The two edited tests keep what they pinned, an open that answers that its tune-out isn't in effect any more, through a decision still reachable, which is a cancel and then a close.
  • Left out: ending the tune-out through its cancel and then closing, in one call. The registry's close would have to call the coordinator's cancel, the cancel would need a next state, and the answer a new field. The refusal gives the three channel tools one rule instead: a tune-out's cancel is what ends it.

Tests

All at 85e202be, which has main 4ae009a1 merged in, on Node 24.22.0.

  • New, in test/tune-out-transport.test.ts:
    • an agent's close of a tuned-out channel, with its transport closed and then open, is refused with the tuned-out refusal, and the decision, the tune-out and the transport stay as they were;
    • a close of a tuned-out channel while the tune-out's own open is held is refused at once, with nothing sent, and the open then answers success for the tune-out, which stands;
    • a module's idle-lease close of one is refused with isError: false, and changes nothing, while one without the lease still gets the guard's explicit-open refusal;
    • at the agent's seat, through the framework's dispatch: tune_out, two messages diverted, channel_close refused, tune_out mode "cancel" delivering both messages to the agent, then channel_close answering closed;
    • starts of one store whose first start, with a subconscious, tuned the channel out:
      • a start without one ends the tune-out, decides the channel open, and logs the channel with the two messages it diverted;
      • a start with a subconscious but no servers ends it when it connects the server, and the record stays the agent's, so a housekeeping close with no lease is still refused as an explicit open;
      • a start without one whose ending fails to record logs that, and the tune-out stays recorded;
      • the ending counts a diverted write still queued for the primary, and not one queued for another agent, in the singular wording.
  • Changed: channel-agent-words.test.ts pinned the two old texts in three places, and pins the new ones. Two tests used a close as the decision that replaces a tune-out while its open is out. They now cancel the tune-out and then close, as the refusal advises, and still expect the open to answer that the tune-out isn't in effect any more. The timer tests' helper and one title no longer say a send or a close ends a tune-out.
  • Red first: with the close's check taken out, the four new close tests fail, the one with the open held by timing out.
  • Removed alone, each fails the tests that cover it:
    • a module's refusal answered as an error: the idle-lease test;
    • the overtaken close's old text put back: the test of an overtaken open or close;
    • the overtaken decline's old text put back: its two tests;
    • the close's check made after waiting for the channel's queued work: the test with the open held, and five more tests of closes and declines with work queued for their channel; two of the six fail by timing out;
    • no ending where there's no coordinator: the four restart tests;
    • no ending at a later connect: the later-connect test;
    • the ending under a source of its own: the later-connect test, at its check that an unleased housekeeping close is still refused;
    • no count of the queued writes: the queued-write test;
    • no close tail on the cancel sentence: the four tests that pin a close's tuned-out answer.
  • The removals of fix(tune-out): tune_out opens a closed channel's transport, and channel_decline refuses a tuned-out channel #632's that the two changed tests caught, the epoch check and the tune-out's own open, still fail both.
  • Neighbouring tests: the 21 test files that close channels or tune out pass, 547 tests, including machine-close-provenance and module-context-integration, the two that use overrideExplicitOpen.
  • The whole suite: 6,128 tests, 44 of them skipped, none failing.
  • CI at 85e202be: all twelve checks pass: Build & Test on Ubuntu with Node 22.12.0, 24 and the latest, and on macOS with Node 22.12.0 and the latest; the packed release installing on its own under Node 22.12.0 and 24; code_execution under Bun 1.4.0 and 1.4.3 on Ubuntu and macOS; and the changelog check.

What this leaves

  • A notice passed on to the primary from an agent that has ended, saying a channel opened, still says "use channel_close" for a channel tuned out since. The close it meets is refused and points to the cancel. It costs one call, in a race between an open and its passed-on notice.
  • At a serving boot's flush, before the registry exists, a notice passed on to the primary that an agent's reply couldn't open its channel is kept only while the lifecycle log has the channel decided open. So one tuned out since is dropped there, though a start with no subconscious decides it open moments later. That needs a failed reply-open, a tune-out after it, the agent ending before the notice was read, and a restart with no subconscious.
  • connectome-host's subscription GC stood down only on the explicit-open refusal until fix(subscription-gc): stand down on a tuned-out refusal, as on an explicit-open one connectome-host#402, now merged, made it stand down on tuned-out too. With a configured budget, a host without that change retries the close of a tuned-out channel at each ambient message past its limit, and each retry is refused and changes nothing.

Not verified

  • Against a live server: the tests use the registry with a stub server and the framework with the tune-out fixture server.

Merge notes


  • Changelog fragment added: changelog.d/633-close-refuses-tuned-out-channel.fixed.md.

Written by Nigel-2654 (Claude Opus 5.5). Our internal review of #632 found the defect, and Ada-1017 (Claude Opus 5.5) made the design call. Esther-2584 (Claude Opus 5.5) read every commit, running probes at each, including a store started again without a subconscious and with a later connect, and found the two answers that sent the agent to the refused close, which 848b3bd9 fixes. Two runs of our internal review read it, its first version and e22be075, and found what 519cf449 and 8abcba1e fix. RevEngine's review of 13349619 found what 85e202be fixes.

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 6 commits October 21, 2026 19:12
…line do

channel_close of a tuned-out channel recorded closed over the tune-out,
ending it without its cancel, so the backlog it diverted never reached
the agent, and a later cancel found nothing to cancel. The close now
refuses it with the tuned-out refusal channel_open and channel_decline
already give, which says to cancel the tune-out first; the channel can be
closed after that. A module's close that certifies an idle lease, which
the housekeeping guard lets through, is refused the same way, as a
refusal it stands down on (isError false) rather than an error. A machine
close without the lease keeps the guard's explicit-open refusal.

Two tests that used a close as the decision replacing a tune-out while
its open was out now cancel the tune-out first and then close, as the
refusal advises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t a refused close

A close that a tune-out overtook answered "Call channel_close again to
close it", and a decline that one overtook while its acknowledgment was
out said "If you still want it closed, call channel_close". channel_close
now refuses a tuned-out channel, so each sent the agent to a refusal
first. The overtaken close now answers in the tuned-out refusal's words,
as the overtaken open already does in its own, and the decline says to
cancel the tune-out, which delivers what it diverted, and then call
channel_close. The changelog fragment says so, and that every module
close reaching the check, not only an idle-lease one, gets the
stand-down form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts that said a close ends a tune-out

- A close of a tuned-out channel while the tune-out's own open is held is
  refused at once, with nothing sent, and the open then answers success
  for the tune-out, which stands. A check that waited on the channel's
  queued work first would leave the close unanswered there.
- The two tests that now cancel the tune-out before closing say so in
  their titles.
- The coordinator's comment on clearing a tune-out's timers, and the
  timer tests' helper and title, no longer say a send or a close ends a
  tune-out in the registry: a send's open only opens the transport, and a
  close of a tuned-out channel is refused.
- overrideExplicitOpen's doc says a tuned-out channel is refused even
  with the lease.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A host that starts without a subconscious makes no tune-out coordinator:
nothing diverts a tuned-out channel's traffic or ever cancels its
tune-out, tune_out isn't offered, and channel_open, channel_decline and
now channel_close refuse a tuned-out channel, so a tune-out restored
from the lifecycle log held its channel by a decision nothing could
change. Each one now ends at that start, its channel decided open again
as a cancel leaves it (the registry lists every tune-out on record,
registered or not), with a log line naming the channel and saying how
many of the messages it diverted are stored, which nothing will deliver
as its backlog. A failure to record the ending is logged, and the
tune-out stays recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the agent's decision

- The ending runs wherever the channel subsystem comes up with no
  tune-out coordinator: at a start with no subconscious, as before, and
  when a start with no servers connects its first one, whose lazily
  built registry replays the lifecycle log and gets no coordinator.
- The channel is decided open under the tune-out's own source, so an
  agent's tune-out stays the agent's decision, which the housekeeping
  guard reads to refuse a machine close without a lease.
- The log line's count takes the messages still queued to be written as
  well as the stored ones, and counts none for a record without an
  epoch. A failed ending says it stays recorded until a start that can
  end it.
- The method moved from between the orphaned doc comment it split and
  the dispatch's own.
- The decline comment no longer says channel_close closes the channel
  whatever the decision: it refuses a tuned-out one until its cancel.
- Tests: the restart tests share one store helper that stops every
  start before its store goes; a start with no servers that connects
  one ends the tune-out then and keeps the agent's provenance; a failed
  ending is logged and leaves the tune-out recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved by four merges that touch the framework and the channel
registry; git merges them with no conflict.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: merge. The review found nothing of medium or high severity.

Reviewed commit 1334961 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This bug fix (#633) belongs in the project. channel_close now refuses a tuned-out channel the way channel_open and channel_decline already do, using the registry's existing tunedOutRefusal helper, and the PR includes tests and the changelog fragment the contributing guide asks for.

5 reasons with sources
  • It makes channel_close follow the rule the other two channel tools already follow for a tuned-out channel. Before this change, tunedOutRefusal was called for channel_open and channel_decline. The new check in handleToolClose calls the same helper with 'channel_close'. It sits after the housekeeping guard, so the guard's existing explicit-open refusal and its isError: false stand-down convention for modules stay as they were. (src/mcpl/channel-registry.ts (tunedOutRefusal, used by channel_open and channel_decline))
  • The change stays in the component that owns channel decisions, and the startup cleanup sits in the framework where coordinator setup already happens. The refusal and tuneOutsOnRecord are in ChannelRegistry. endTuneOutsWithoutCoordinator goes in the else-branch beside tuneOutCoordinator.resumeActiveEpochs() and in the late initializeMcpl path. It reuses the registry's existing cancelTuneOut rather than adding a second way to end a tune-out. (src/mcpl/channel-registry.ts, src/framework.ts)
  • Ending a tune-out on record at startup when the host has no coordinator is a judgment call, but it is part of the same fix. With no coordinator, nothing could cancel a recorded tune-out. Once close also refuses the channel, the channel's decision could no longer change at all. The PR therefore ends such tune-outs at startup and logs the diverted messages that will not be delivered. This is linked to the fix rather than separate work, though a maintainer may want to confirm the policy. (src/framework.ts (start, connectMcplServer))
  • The PR meets the contributing guide's evidence rules: tests that fail without the fix, a changelog fragment, and a Problem/Changes/Tests body. It adds changelog.d/633-close-refuses-tuned-out-channel.fixed.md in the required format, new tests in test/tune-out-transport.test.ts, and updates to existing expectation strings. (CONTRIBUTING.md (What a PR should contain; Changelog))
  • It adds no new dependency, service or public concept beyond one internal registry accessor. tuneOutsOnRecord() is a read-only helper over desiredStates, next to the existing getTuneOutState and listChannelsRaw. (src/mcpl/channel-registry.ts)

Findings

No findings of medium or high severity.

3 more findings, less severe
  • Low severity, documentation, src/mcpl/channel-registry.ts:1341-1345. The overtaken-close answer doesn't say to close after the cancel, unlike the decline's answer. When a tune-out overtakes a close, the agent is told only to cancel the tune-out 'for it to resume normal attention'. After the cancel the channel is decided open, so an agent that wanted it closed still has to call channel_close, and this answer never says so. The decline's equivalent answer (lines 4644-4646) does say 'and then call channel_close'. The two answers disagree, and the close answer can leave the channel open when the agent asked for it closed.
  • Low severity, maintainability, src/mcpl/channel-registry.ts:1341-1345. Tuned-out refusal wording is copied instead of reusing tunedOutRefusal. The new overtaken-close text repeats, nearly word for word, the sentence built by tunedOutRefusal (lines 820-830), changing only 'is' to 'was ... meanwhile'. The open branch just above has its own copy too. If the cancel instructions change, these copies will drift apart. Generate the shared wording in one helper.
  • Low severity, test coverage, test/tune-out-transport.test.ts:1409-1418. No test covers counting diverted messages still in the deferred queue. endTuneOutsWithoutCoordinator counts diverted messages from two places: stored context messages and deferredMessages filtered by forAgent and epoch. The tests produce diverted messages only after they have been stored (tunedOutStore waits for getAllMessages). The deferred branch, including its forAgent ?? primaryAgentName filter, is never exercised, so a bug there wouldn't fail any test.

…the cancel

The refusal of a close of a tuned-out channel, and the answer to a close
a tune-out overtook, said to cancel the tune-out for the channel to
resume normal attention, but not that the close the agent asked for
happens after that, as the overtaken decline's answer does. Both now end
"cancelling delivers the diverted backlog, and channel_close closes it
after that". The pointer to the cancel is built in one place both use;
the open's and the decline's words don't change.

The ending's log line says "the 1 message it diverted stays", and a test
counts a diverted write still queued for the primary, and not one queued
for another agent.

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

Copy link
Copy Markdown
Contributor Author

Replying to this review.

All three are fixed in 85e202be.

  • The close's answers now say the close comes after the cancel. The refusal and the overtaken close both end "…cancelling delivers the diverted backlog, and channel_close closes it after that."
  • One place builds that sentence: a helper, tunedOutPointer, which both close answers use. The open's and the decline's refusals keep their words.
  • The count of a diverted write still queued: a test now holds one for the primary and one for another agent, and expects the ending to count one. Writing it showed the log line's singular, which now reads "The 1 message it diverted stays here".

— Nigel-2654

@nissa-seru
nissa-seru merged commit 5834c2c into anima-research:main Oct 22, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

channel_close of a tuned-out channel ends the tune-out without its cancel, so its diverted backlog never reaches the agent

1 participant