Skip to content

fix(mcpl): a retarget opens its new target past work a removal cancels - #594

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/f332-retarget-after-removal
Oct 19, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/f332-retarget-after-removal

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A retarget of a channel decided open (or tuned out) now opens the new target when the only lifecycle work outstanding for the channel was queued before its server's registrations were last removed. That work cancels when it would run, so before, nothing opened the new target and the channel stayed unopened there although decided open.

Problem

Line numbers are at this pull request's head, except where they say main.

handleChanged (src/mcpl/channel-registry.ts) reconciles a retargeted channel decided open or tuned out only when no lifecycle work is outstanding for it (lifecycleOutstanding.has, line 1107 on main). The reason is that an operation queued or running reads its target again before it confirms anything, so it opens the new target itself.

An operation queued before removeServer doesn't. At its removal-epoch check (runLifecycleOperation, line 2612) it throws ChannelLifecycleCancelledError and opens nothing. Where that was all that was outstanding, the retarget queued nothing.

That takes a channel registered again after the removal, before the old work settles, in a way that queues no lifecycle work:

  • from a push (ensureChannelRegistered);
  • by a registration while the host doesn't direct the server's lifecycle (channels.lifecycle), whose grant then comes back.

A registration that reconciles queues new work, and that work carries the decision out.

Changes

  • The retarget check (line 1109) takes outstandingWorkCarriesOut (line 2745), which fix(mcpl): an open by speech or a reply that fails puts back the decision it replaced #568 added for the failed-open put-back (line 3142). It counts outstanding work only when its newest operation was queued since the server's last removal (lifecycleQueuedRemovals).
  • Comments. The retarget check's comment says that work queued before the server's last removal cancels instead of opening the new target. The predicate's doc and the put-back's comment now say it's a registration by channels/register, or as added by channels/changed, that carries a decision out after a removal, since a push's registration queues no lifecycle work.

What each part is for

  • The condition is the fix. It reuses the put-back's predicate, so both sites read outstanding work the same way.
  • The tests pin it from both sides.
    • Put back to main's check, the condition fails the four new tests.
    • Made to reconcile every retarget, it fails ten tests: nine existing ones, such as "descriptor updates during an open refresh its target, but labels alone do not reopen", and the new one that retargets while work queued since the removal is in flight.
  • The changelog fragment is the repository's required entry, saying what a server meets.
  • Cut. Resetting the server's outstanding counts at removeServer instead, with main's plain check, fails two tests. One is the new test that retargets while work queued since the removal is in flight. The other is fix(mcpl): an open by speech or a reply that fails puts back the decision it replaced #568's "when a reply's open fails while its server is removed and added again as initially open, the default put back follows the new registration". A cancelled operation decrements its channel's count when it settles (line 2727), so the reset lets it delete the count of the work queued since the removal.

What callers will notice

  • An MCPL server now gets a channels/open for the new target when it retargets a channel decided open or tuned out that came back after the server's removal by one of those two routes, before the old work settled. The host sends it where it directs the server's lifecycle (channels.lifecycle) at the retarget.
  • Residents get no new tool or system text. A channel they decided open now receives its traffic at the new target, as decided.

Tests

  • channel-lifecycle-order. Four new tests. In each, a channel's open to A is held, and then its server is removed.
    • In two of them, for a channel decided open and one tuned out, the channel comes back from a push and is retargeted to B.
    • In the third, it's registered again without channels.lifecycle, and retargeted once the grant is back.
    • Each of the three expects the calls to be open A, then open B, and the channel open.
    • The fourth retargets to B after a push's registration, then to C while the open to B, queued since the removal, is in flight. It expects exactly open A, open B, open C.
  • Red-first. With main's source and this change's test file, the four new tests fail and the file's other 128 pass. Each fails having sent only open A.
  • Removals. Each of the three changes in "What each part is for" ran against the test file, with the results given there. With the source restored, the file passes.
  • Full check, on Linux with Node 24.22.0: npm run typecheck is clean, and npm test ran 5,407 tests, 5,380 passing, 0 failing and 27 skipped. Each of the 253 test files run alone gave the same totals. main's CI at 63835621 (Ubuntu, Node 24.21.0) ran 5,403 tests, 5,375 passing and 28 skipped. The four more here are the new tests. The one skip fewer is a Bun test, which CI skips because Bun isn't on its PATH.
  • CI on this head merged with main f63bbfa8 (run 39321194725) passes all four Build & Test legs, Ubuntu and macOS each with Node 20 and 24, and the packed install. The changelog check passes too (run 39321194547).
  • Reviews. An internal review read the change, and this revision answers its findings about the change. A colleague read each revision.

Not verified

  • No MCPL server process. The tests drive the registry with the lifecycle-order file's fixture server.

  • Changelog fragment added: changelog.d/retarget-after-removal-opens.fixed.md.

🤖 Written by Cuthbert-2453 (Claude Opus 5.5), contributing through nissa-seru. Petra-1012 (Claude Opus 5.5) found it reading #568, and read each revision.

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

The retarget check in handleChanged left a channel decided open (or
tuned out) to any lifecycle work outstanding for it, since such work
reads its target again before it confirms anything. Work queued before
its server's registrations were removed cancels when it would run
instead, so where that was all that was outstanding, nothing opened the
new target. The check now takes outstandingWorkCarriesOut, which counts
outstanding work only when its newest operation was queued since the
server's last removal, as the failed-open put-back already does.

It needs the channel registered again in a way that queues no lifecycle
work, before the old work settles: from a push (ensureChannelRegistered),
or while the host doesn't direct the server's lifecycle. The predicate's
doc and the put-back's comment now say that a push's registration queues
nothing, so it's a registration by channels/register, or as added by
channels/changed, that carries a decision out after a removal.

Tests: a channel decided open, and one tuned out, registered again from
a push after its server's removal while its old target's open is held,
then retargeted, opens the new target; so does one registered again
without channels.lifecycle and retargeted once the grant is back. And a
retarget while an open queued since the removal is in flight queues
nothing more: that open goes to the new target, once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 2a56c44 into anima-research:main Oct 19, 2026
6 checks passed
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 20, 2026
Main has moved since the last merge (037aa79), by 26 pull requests,
among them anima-research#589 (the channel tools and speech say what happened),
anima-research#597 (publishForAgent's refusal says why), anima-research#595 (channel_decline's
status), anima-research#601, anima-research#568 and anima-research#594 (channel opens and retargets), anima-research#495, anima-research#596
and anima-research#599 (code_execution's channel and how a script's ending reads),
anima-research#617 (a sleeping agent wakes for the host's heartbeat server) and anima-research#616
(stop() ends each live conversation fork).

Two files conflict:
- src/mcpl/channel-registry.ts: routeSpeech's no-locus branch. Main
  gives the agent a reason in its own words and keeps the old text for
  the log; this branch rewrote the comment above it. Both are kept.
- test/event-gate.test.ts: both sides append a describe block after the
  quiesce tests. Both blocks are kept, this branch's first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant