Repository navigation
fix(mcpl): publish() publishes only into a registration confirmed open, under the grant as it is then - #569
Merged
nissa-seru merged 1 commit intoOct 19, 2026
Conversation
|
Required label not found on this PR. |
…n, under the grant as it is then publish() checks the channel's registration once more just before it publishes, after every await: the lifecycle work's, the open's and the opening's notice. That check refused a registration that was gone or inbound-only, and sent into any other. A server's channels/changed can change the channel in that window, for example during the opening's notice, in two ways that leave the registration unconfirmed: a new registration of the channel makes a fresh entry with open: false, and a retarget keeps the entry but marks it unconfirmed (open: false). Either way the reconcile queues an open that nothing has answered yet, so the words went out against the invariant publish() states, that nothing is sent into a closed channel. A close decided in that window was passed over too, though waitForOpenChannel refuses words whose channel is decided closed while they wait. And the check didn't read the grant: channels.publish was checked once, before the waits, so a grant reduced during them still let the words out. Now the loop in the last check runs while the registration in the map isn't confirmed open, or its close stands, and it isn't inbound-only. The words wait for its queued lifecycle work with waitForOpenChannel, as at the first wait, which refuses what that work leaves unconfirmed or with its close standing, and the check runs again on the registration that stands. The loop opens nothing itself. waitForOpenChannel returns only a registration it found open and whose close doesn't stand, or throws, so the loop ends unless the channel keeps changing. A failure there reads as at the first wait, "lifecycle work prevented delivery: ...". The loop has no bound of its own: each further pass needs the channel to change again after a wait, and waitForOpenChannel already waits for as long as lifecycle work keeps coming. An inbound-only registration is refused at once, without waiting for its open. Then the check reads the grant as it is, and refuses one without channels.publish in the words the check before the waits uses, so a reduction holds for words already on their way (SPEC 6.7). One helper builds each of the two refusals for both places it's made. A decided close read two ways on a server without channels.lifecycle: the open step's engagesDecidedClosed passes over it there, since the host can't close the channel and words go into its open transport, while waitForOpenChannel refused it whatever the grant. One helper, closeStands(), now reads it for the loop and for waitForOpenChannel alike: a decided close holds words back only where the host directs the server's lifecycle. That changes waitForOpenChannel only for a server without the grant that still has lifecycle work queued, where a decided close no longer refuses words into an open transport. Tests: new cases in channel-lifecycle-order start by changing x, or its server's grant, during its opening's notice. When x is registered again with the new open held, nothing is published while that open is in flight; when it's answered, the words go out after it, and when it fails, nothing goes out and the route failure says lifecycle work prevented delivery. When x is retargeted, nothing is published until the new target's open is answered, and a retarget with nothing queued for it, once the grant has lost channels.lifecycle, is refused. When x is registered again, and again while the words wait, they wait through the newest registration's lifecycle work and are delivered once. When the agent closes x during the notice, the words wait for the close and are refused. When x is registered again as inbound-only, the words are refused for the declaration at once. When the grant loses channels.publish, during the notice or while the words wait for a new registration's open, the words are refused in the grant's words, and the first case checks the outcome's destination. Once the grant loses channels.lifecycle and the agent closes x, a new registration confirmed open by its traffic takes the words, and so does one whose open is answered while the words wait. The draft refused for a pending close also checks the failed outcome's destination. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
force-pushed
the
fix/publish-waits-for-new-registration
branch
from
October 19, 2026 10:57
a231455 to
2a8fcd6
Compare
tel-0s
added a commit
that referenced
this pull request
Oct 19, 2026
…ge entry for bounded jobs, the batched-wake hold What main brings since 9c6964c: 0.23.0 and 0.23.1 — #360 (hold-round speech), #250 (surgery marks opt-in), #463 (the workspace edit planner), #168 (non-conversational marker tags: a reaction, edit or deletion never retargets the publish locus, apart from an edit the coalescer marks as the agent's first view), #558 (every ephemeral agent's usage in one shared entry), #570 (the batched-wake tests hold the gate's batch), #569, #565, #566, #571, #572, #575, #576, #578 and the 0.23.x merge-backs. Conflicts in five files, resolved by composition: - src/agent.ts: the request strips the framework's token-estimate stamp (main) and then presents the agent's own turns under its name (ours). - src/framework.ts: `ephemeralCandidates` keeps this branch's record (config, spec, resume) beside main's `ephemeralAgents` WeakSet, and createEphemeralAgent adds to both; `isAddressedMessage` stays tag-only (main's second argument is unread) with main's `readsDeferredWrite` spread; the re-pin rule's doc is main's with the `speech.locus` sentence; the turn-start flush is main's `flushForStart`, which gains this branch's `releaseLandedMail` so held mail whose message lands is released. - src/mcpl/channel-registry.ts: the three Dendrite options (`latestInboundLocus`, `onToolClose`, `focusChannelResolver`) beside main's `onRouteFailure` doc; `markAccepted` takes main's `firstView` but records no publish locus — admission does, through `noteDelivered`, and the framework's three `noteDelivered` sites now apply main's marker rule (`isMarkerTagged(tags, firstView)`), so a reaction, edit or deletion is never the latest delivered inbound either; `channel_publish`'s default comment says the same. - test/batched-wake-locus.test.ts: this branch's `batchedWakeRig` keeps its shape and gains main's `holdBatches`, reading the rig's framework; every test that pairs two sends holds the batch, the two ambient-only tests included. - test/present-while-acting.test.ts: `createFramework` takes `speech`, `maxStreamTokens` and main's `proseRouting`; main's new re-pin tests (traces and typing after a re-pin, the budget restart, the inbound-only arrival) run under `LEGACY`, the latest-inbound rule the re-pin belongs to, beside this branch's default-rule test. Three seams the suite found, all at the registry's reply anchor, which main sets where it accepts a message and this branch sets where admission delivers one: - #168's marker rule now lives in `noteDelivered` (tags and the coalescer's first-view mark are passed by the framework's three delivery sites), so the registry's unit tests exercise it as the framework calls it. - `buildChannelContext` reported no `incoming` when the speech fallback was null, and no context at all when nothing was open and no outgoing channel resolved — under `speech.locus: 'delivery'` that hid the reply anchor whenever the latest inbound wasn't a speech fallback. The anchor now depends only on `takesGlobalFallback` (an ephemeral run has none), and a context with an anchor is returned whatever else it lacks. - A coalesced acceptance's anchor is committed when the acceptance is acknowledged, not when the coalescer delivers: an acceptance whose commit fails after its delivery moves no anchor, and the retry the receipt answers commits the one the first attempt parked (`parkedAnchors`, keyed by server and event id, bounded). #157's tests pinned both halves. Test adaptations beyond the conflicts: #157's two re-pin tests and outgoing-stream's re-pin test run under the legacy locus rule, as the others do; two compiled tests main deleted (`mcpl-awareness-barrier`, `prose-stream-router`) were still in `dist/test` and failing from there — removed, since tsc doesn't clean outputs whose sources are gone. Two lines git doesn't show, named by nissa-seru's review of #546: - #558 reads `config.subconscious.name` for the shared usage entry's exemptions; a forks reader has no agent of its own, so the name is read only when `reader !== 'forks'` (the union has none otherwise). - resumeAgent makes its agent with `new Agent`, so it adds the agent to `ephemeralAgents` itself: a resumed job's usage counts in the shared entry, not under its own name. Composed rig: npm test 5408 tests, 5378 pass, 0 fail, 30 skipped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 24, 2026
…s rule on main's channel machinery main moved 1,643 commits since anima-research#330's last merge (0756302). Routing became ChannelRef-based (anima-research#710), every lifecycle operation of a channel now goes through one queue (anima-research#210), publish() refuses words for a close that stands (anima-research#569), a failed open by speech puts back the decision it replaced (anima-research#568), and the channel texts were reworded (anima-research#589, anima-research#675). Six files conflicted, in 45 hunks. The resolution starts from main's side of each of them and puts anima-research#330's rule back on main's machinery, so the diff against main is anima-research#330's rule and nothing anima-research#330 used to carry that main now does. The rule is anima-research#330's, unchanged: speech never undoes the agent's own close or decline, or a module's close; it opens every other closed channel, as before. Where it now lives: - speechLeavesAsItStands(entry) decides, on main's reading of what needs an open (a channel that isn't open, or whose close stands): words go into such a channel as it stands where the decision on record is the agent's close or decline or a module's close, whatever its transport, or where its server is up and this host doesn't direct its lifecycle. speechOpens(channel) is every other channel that needs an open. A tuned-out channel's transport is reconfirmed as on main, and its tune-out stays. A server that isn't up isn't counted as one this host can't direct, though a drop empties its grant until its next exchange: speech there is refused as on main, by the open's failure or, where the grant is empty, by publish()'s grant check. - publish() asks it at its first wait, its open step and its last check. Where speech opens the channel, main's path is unchanged, failure texts included: a failed open sends nothing. A wait or an open that fails where the channel is by then one speech leaves as it stands, as when the agent closed or declined it while the words waited, gives way, and the words go in. Two changes from main follow, both named for the release: anima-research#569's last check no longer refuses words for a channel the agent or a module closed itself (a module's close was recorded as the agent's on main), and a closed channel on a server that's up but whose grant lacks channels.lifecycle gets the words, where main failed them. - openIfClosedForSend() stays, and asks the same after main's own already-open check: a channel a send leaves as it stands, found so or become so while its wait or open was out, answers a new status, stays-closed, or already-open where its transport is open with no close standing, and opens nothing. Every other channel opens as on main, one open at the transport with no decision on record included, so the send's engagement is still recorded as an open decision there. main's open-failed notice, opening and selectedForOpening are as on main. resolveSendTarget() is the synchronous lookup the send's note needs before its answer is queued (sendTargets, main's lookup). - setDesiredState keeps main's rule (the agent's decision records itself over a record that isn't the agent's) and adds anima-research#330's: module-tool counts as a deliberate close, the latest deliberate close records itself over a different one (a decline after a close, and the reverse), and a machine's close of a channel with no record writes default-closed (anima-research#417). - channel_decline is refused only for a channel open at the transport. Its close goes through main's queue wherever the transport may be open; anima-research#330's own channels/close call is gone. - The note, the explicit-mode line (its >> deliveries are now recorded for it), the [tuned out] line, the script notice, closed_channel_note, the invitation's option 1 and its chat:dm skip, the sleep words, channel_close's description, and the opened notices' last sentence are anima-research#330's texts on main's code. The [tuned out] line now tells the agent main's behaviour: a send has kept a tune-out since 0.23.0. The send's note is appended to the answer's text or blocks, and a failed lookup leaves the answer as it was. Dropped, since main does them: the optional serverId on isChannelOpen and getDescriptor and entryFor (main's ChannelRef reads), canOpenChannels (directsLifecycle), openForSpeech, R1-4's publish failed (main's publishErrorOutcome), the flag-follows-decision lines in openChannelNow and reconciliation (main's queue settles that race), "the words go out whatever the open does" (its reason, a server that can't open channels, is now left as it stands before any open), and the engagement record anima-research#330 moved before the send's answer (main records it, by ChannelRef). Tests: anima-research#330's send-into-closed-channel, registry, invitation and explicit-mode tests are adapted to main's API, with new ones for the words that wait while the agent closes or declines, a close still on its way, a failed close, and the send's statuses. Tests of main's that pinned the old behaviour are turned round, or moved to a machine's close, which still gets main's refusals: - channel-lifecycle-order: the draft and speech waits on a close, the close during an opening's notice (with the agent's close beside it), a retarget and a closed transport on a server without channels.lifecycle, a fresh delivery, a backscroll join, the retarget joins, a reply's put-back, speech's put-back and its lift, the failed-close engagement (now both closers), the replies on a server without the grant, the queued-close reply, a reply's open overtaken by the agent's close, and the close over a decline; - channel-agent-words: speech on a server that's up without channels.lifecycle (now delivered; connecting still fails as on main), its close during later work (a machine's now), and the decline refusal's words, which shared-channel-id pins too; - inbound-source: its decorated push is now a mention from a channel the agent closed, since a DM carries no invitation; - the opened notices' last sentence in trunk-channel-routing, route-failure-targeting and channel-lifecycle-order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
publish()now publishes only into a channel registration that is confirmed open when its last check runs, and under the grant as it is then. If the registration it finds there is unconfirmed, as a new registration or a retarget during its waits leaves it, the words wait for that registration's queued lifecycle work, and the check runs again. A close decided before that check now stands there, as it does when decided while the words wait, where the host directs the server's lifecycle. Until now that check refused only a registration that was gone or inbound-only, and published into any other, even one whose open hadn't been answered.This extends the last check that #350 added.
Problem
publish()waits for a channel's queued lifecycle work and its open, announces an opening it made, and then checks the channel's registration once more just before it publishes (#350). A server'schannels/changedcan change the channel in between, for example during the opening's notice, in two ways that leave the registration unconfirmed:registerEntrymakes a fresh entry withopen: false, and the new registration's reconcile queues the open its decided state asks for.open: false) and an open of the new target is queued.The last check found neither gone nor inbound-only, so the words went out before anything had confirmed the registration open. That goes against
publish()'s stated invariant that nothing is sent into a closed channel. Runs onmainbefore this change show both cases: with the new open held, the words were published while it was in flight.A close decided in the same window, for example by the agent during the notice, was passed over too, though
waitForOpenChannelrefuses words whose channel is decided closed while they wait.A decided close also read two ways on a server without
channels.lifecycle. The open step (engagesDecidedClosed) passes over it there: the host can't close the channel, so words go into its open transport, as the existing test "speech into a channel decided closed keeps publishing where no open can succeed, without the lifecycle grant" pins.waitForOpenChannelrefused it whatever the grant. The two differ only on a server without the grant that still has lifecycle work queued, as after the grant is withdrawn while the words wait.The same check didn't read the grant.
channels.publishwas checked once, before the waits, so a grant reduced during them, or during the notice, still let the words out. In the MCPL spec's §6.7, a security-reducing change "takes effect atomically first", since "reducing late leaves a window of over-permission".Changes
waitForOpenChannel, as at the first wait, which refuses what that work leaves unconfirmed or with its close standing. The check then runs again on the registration that stands. The loop opens nothing itself. A failure there reads as at the first wait, "lifecycle work prevented delivery: …".closeStands(), says a decided close holds words back only where the host directs the server's lifecycle, asengagesDecidedClosedalready reads it. The loop andwaitForOpenChannel's refusal both use it. On a server without the grant, a decided close no longer refuses words that waited for queued lifecycle work.channels.publishin the words the check before the waits uses.publish()'s andwaitForOpenChannel's docs say so, and the changelog fragmentchangelog.d/publish-waits-for-new-registration.fixed.mddescribes the three changes and the two effects below.What each part is for
waitForOpenChannelalready waits for as long as lifecycle work keeps coming.waitForOpenChannelreturns only a registration it found open and whose close doesn't stand, or throws.open: false, and a retarget is marked unconfirmed, soopencatches both, whichever registration the map holds.openalone would miss it.closeStands()'s limit to servers whose lifecycle the host directs, shared withwaitForOpenChannel. Without that grant the host can't close the channel. With one helper, a decided close can't hold the words back at one wait and let them through at another.Two more effects follow from the loop, both refusals the invariant asks for:
channels.lifecycleis refused, as the host's own open would be there. If the server's traffic has confirmed the retarget open by then, the words go out.Tests
In
test/channel-lifecycle-order.test.ts, each new case starts by changingx, or its server's grant, during its opening's notice:channels.publish, during the notice, or while the words wait for a new registration's open. The words are refused in the grant's words. The first case also checks the outcome's destination.xduring the notice. The words wait for the close and are refused, and the channel ends closed.channels.lifecycle. The words are refused, and no open is made for the new target.channels.lifecycleand the agent closesx. The words go out into the open transport.channels.lifecycleand the agent closesxwhile the words wait. The close is refused for the grant, the new registration's open is answered, and the words go out.At
2a8fcd6a(code and tests as ata2314558) on Linux, with Node 24.22.0,tsc --noEmitand the build are clean, and the whole suite passes: 4988 tests, 4961 pass and 27 skipped, none failing.channel-lifecycle-orderruns 86 of 86.With
main's source (26653d36) and this change's tests, eight of the new cases fail:The other new cases pass there, since nothing at the last check refused words on
main: the inbound-only case, the two that end in the words going out once the grant loseschannels.lifecycle, and the destination check.Each part, removed on its own, fails a test:
closeStands()'s limit to servers whose lifecycle the host directs, which also fails the existing "speech into a channel decided closed keeps publishing where no open can succeed, without the lifecycle grant";waitForOpenChannelalone;Without the loop's re-read of the map, the loop never gives the event loop a turn: the test runner's own 60 s timeout never fires, and the removal run stops the file at its 300 s limit.
Not verified
channels.lifecycle(the second effect). That rests on readingmarkAccepted. The test for a channel registered again and confirmed open by its traffic runs the same path for a new registration.changelog.d/publish-waits-for-new-registration.fixed.mdWritten by Cordelia-2434 (Claude Opus 5.5), contributing through nissa-seru, to design calls by Ada-1017 (Claude Opus 5.5). Desmond-2579 (Claude Opus 5.5) read the change before it opened, and our internal review read it.
Description checked and posted by Daniel-2612 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.