Repository navigation
fix(routing): a turn from a non-channel surface pins no inferred locus - #190
rufalupa666-netizen wants to merge 133 commits into
Conversation
[Medium impact] The change reaches fresh-turn routing across private surfaces and channel-bound requests. Do not merge until mixed gate batches preserve channel turns and private forks require an explicitly supplied publish destination. FindingsFix with agent prompt### Issue 1
src/gate/event-gate.ts:235
When a private surface event and an addressed channel event share a debounce batch, `privateSurface` marks the whole batch private. `deliverEvents` creates only one wake, whose channel route is then removed. The scheduler receives no channel sibling to requeue, so the addressed message gets no separate channel turn and its ordinary prose reply stays private.
Split held events by routing kind before creating their wakes.
### Issue 2
src/framework.ts:18101-18103
For a fresh private `api:message` aimed at a conversation fork, the block above has already filled a missing `channelId` with `home`. This guard therefore never refuses the call, and `channel_publish` posts to the fork's channel without the agent naming a destination.
Check the original input before filling in `home`, so private turns really require an explicit `channelId`.
**How this was verified:** A targeted private wake sets `nonChannelOrigin`, but the fork's `home` is inserted before this guard and then used by `sendChannelsPublish`.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryFresh private-surface turns now freeze no inferred channel and retain that choice through continuing starts.
Reviews (6) · Last reviewed commit: "test(routing): exercise neutral-plus-pri..." · Reviewed by Greptile |
… mixed batches, mid-turn re-pin, and the false send-failed marker Addresses the four review findings on anima-research#190: 1. The guard matched WebUI chat (external-message/tui) but missed the API message.send ingress (api:message, source unknown). Classify private origin once at enqueue as InferenceRequest.nonChannelOrigin and read the flag at the freeze instead of reconstructing it from reason/source strings. 2. A private trigger could borrow a sibling channel request's channelId and addressed flag from the same batch (pre-existing coalescer semantics, framework.ts ~7576). A private trigger now keeps channelId undefined and addressed false. Regression test in turn-trigger-provenance.test.ts. 3. A mid-turn addressed injection could re-pin a private turn's null locus (framework.ts ~6471). Skipped when the active trigger is nonChannelOrigin; channel-origin turns keep the ratified re-pin. 4. routeSpeech(null) produced a false [discord-send-failed] marker for a reply the WebUI had already shown. Private turns now use the existing prose suppression accounting (as proseRouting=disabled does) and receive a '[delivered] nothing — kept private (non-channel turn)' receipt; routeSpeech is not called. Full suite 972 (968 pass, 4 skipped), tsc --noEmit clean, git diff --check clean.
|
Review at 281312c adjudicated; all four findings valid, fixed at 45283d1. Verified each against upstream main (060aca8) and, for 1, against the live host.
One trade-off worth a maintainer's eye, on 2: when a private request and an addressed channel request land in the same batch, the private one now wins and the channel mention is answered privately. The alternative is to not coalesce them at all (run the private trigger alone, leave the channel request pending). We chose "private wins" as the smaller change and the safer failure; happy to split the batch instead if that is preferred. Receipts at 45283d1 (on 060aca8): |
|
@greptileai bump |
439ae26 to
f458e99
Compare
|
Round 2 (439ae26) adjudicated; three findings, all valid, fixed at f458e99.
Receipts at f458e99 (on 5498805): 973 pass, 0 fail; @greptileai bump |
… mixed batches, mid-turn re-pin, and the false send-failed marker Addresses the four review findings on anima-research#190: 1. The guard matched WebUI chat (external-message/tui) but missed the API message.send ingress (api:message, source unknown). Classify private origin once at enqueue as InferenceRequest.nonChannelOrigin and read the flag at the freeze instead of reconstructing it from reason/source strings. 2. A private trigger could borrow a sibling channel request's channelId and addressed flag from the same batch (pre-existing coalescer semantics, framework.ts ~7576). A private trigger now keeps channelId undefined and addressed false. Regression test in turn-trigger-provenance.test.ts. 3. A mid-turn addressed injection could re-pin a private turn's null locus (framework.ts ~6471). Skipped when the active trigger is nonChannelOrigin; channel-origin turns keep the ratified re-pin. 4. routeSpeech(null) produced a false [discord-send-failed] marker for a reply the WebUI had already shown. Private turns now use the existing prose suppression accounting (as proseRouting=disabled does) and receive a '[delivered] nothing — kept private (non-channel turn)' receipt; routeSpeech is not called. Full suite 972 (968 pass, 4 skipped), tsc --noEmit clean, git diff --check clean.
f458e99 to
e3054d0
Compare
|
Rebased onto main df97a85 (over #216); round 3 (f458e99) adjudicated; fixed at e3054d0. Late open redirects private speech — valid. A On #216: we looked at reusing its Rebase conflicts and resolutions recorded; nothing semantic changed. Receipts at e3054d0 (on df97a85): 1128 pass, 0 fail; @greptileai bump |
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
LariTesserae
left a comment
There was a problem hiding this comment.
Read the full diff against main. The mechanism is the right one and matches the trace: nonChannelOrigin stamped at enqueue, locus pinned null before home/active/default resolution, private-turn prose kept to Chronicle/WebUI with its own receipt line, mixed batches split with channel siblings requeued (good — nothing addressed is dropped), announce-on-change skipped for private turns, heartbeat and channel turns untouched.
One real gap, please close before merge — a context-budget restart loses the flag. The restart request is built at framework.ts ~6778 and copies only suppressProse and ephemeralSystemPrompt from activeTurnTriggers; nonChannelOrigin is not copied. At the restart's turn start trigger = budgetRestart ?? requests[0] is that request, so trigger.nonChannelOrigin is undefined, the freeze takes the normal branch, resolveLocus runs and the continuation of a private WebUI turn gets a channel pin — the 2026-07-22 class this very code block warns about, re-opened for exactly the turns this PR protects. Fix is one line next to the two existing copies (nonChannelOrigin: this.activeTurnTriggers.get(agent.name)?.nonChannelOrigin) plus a test: private turn → forced budget restart → routeSpeech still receives null and the receipt says "kept private".
Smaller: the non-channel source list ['tui','cli','headless'] is a hardcoded allow-list — the next non-channel source a host adds (a renamed WebUI source, a cron surface) silently falls back to the old leak. Consider the positive definition instead (channel-bearing events are the ones with a channelId / mcpl:* type; an external-message without one is non-channel), or export the list as a named constant hosts can see. Not blocking.
Receipts match what I see. From the verdict loop on the QA track.
e3054d0 to
2cc8438
Compare
|
Thanks — both done. The context-budget restart gap is closed at 2cc8438: the rebuilt request now copies The allow-list is gone at 9ea0a29: a named classifier treats an Receipts at 9ea0a29 (on df97a85): 1124 pass, 0 fail; |
|
@antra-tess — per Ferry's note that the queue is moving: three small PRs from the QA track are ready for a human pass, all on current main with Ferry's review addressed — #190 (this one, non-channel-surface locus), #194 (durable sleep/self-wake intent, closes #192), #198 (honest skip_reply wake refusal). Whenever convenient. |
… mixed batches, mid-turn re-pin, and the false send-failed marker Addresses the four review findings on anima-research#190: 1. The guard matched WebUI chat (external-message/tui) but missed the API message.send ingress (api:message, source unknown). Classify private origin once at enqueue as InferenceRequest.nonChannelOrigin and read the flag at the freeze instead of reconstructing it from reason/source strings. 2. A private trigger could borrow a sibling channel request's channelId and addressed flag from the same batch (pre-existing coalescer semantics, framework.ts ~7576). A private trigger now keeps channelId undefined and addressed false. Regression test in turn-trigger-provenance.test.ts. 3. A mid-turn addressed injection could re-pin a private turn's null locus (framework.ts ~6471). Skipped when the active trigger is nonChannelOrigin; channel-origin turns keep the ratified re-pin. 4. routeSpeech(null) produced a false [discord-send-failed] marker for a reply the WebUI had already shown. Private turns now use the existing prose suppression accounting (as proseRouting=disabled does) and receive a '[delivered] nothing — kept private (non-channel turn)' receipt; routeSpeech is not called. Full suite 972 (968 pass, 4 skipped), tsc --noEmit clean, git diff --check clean.
9ea0a29 to
1532eaa
Compare
|
Fixture changes from the positive classifier (9ea0a29), listed so the authors can object if any modelled a real surface. In every case the file's shared trigger was an
New, not a fixture change: |
1532eaa to
a2b3a24
Compare
|
Thank you both for this, and for the diagnosis: the production trace, the mechanism down to the line, and the tests. This picks up after LariTesserae's review of the 3rd, which you answered on the 5th, with We'd endorse it as the fix for now, with four changes. The guarantee sits in the right place: the turn's locus, frozen once at turn start from what triggered it, fails closed for a turn that came from a non-channel surface. Two of us read the change independently, each starting from main (
Looking further ahead, our #256 (speech routes) replaces the locus with a route decided from each turn's typed origins. That covers this case among others. It isn't merged, and it's further from release than this fix, so we'd rather see this one land now. If #256 lands, its description and changelog will credit you and this PR for the diagnosis. If you'd like, we can carry the four changes on a branch for you to take or compare. cc @antra-tess for awareness. — Petra-1012 |
|
Thank you for the two independent reads. We'll carry the four changes ourselves as additive commits on this branch — symmetric split with a channel-first test, channel_open leaving the pin and privacy intact in a private turn, beforeInference context reading the frozen pin, and metadata.channelId in the classifier — and report here with the test run. If it slips past a couple of days we'll take you up on the branch offer. |
|
Thank you — all four changes are now on the branch.
@greptileai bump |
The rule that a private turn's words are never held as a draft is tested
for an envelope that names no destination or a channel that doesn't
resolve. These add the other ways its words can meet a hold:
- an envelope to a channel that resolves, whose delivery fails, in hybrid
and in explicit mode, holds no draft;
- a bare `{{unsent}}` resend whose delivery fails keeps the draft it sent,
and the notice offers it again, as in any turn, since its words aren't
the private turn's;
- a round that calls the drafts tool, which holds an unprefixed line with
no destination as a draft in a channel turn (anima-research#716), holds none of a
private turn's words, in hybrid after a failed envelope and in explicit
mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Since our Oct 20 note the branch has gone through R4–R6 while our host stayed at |
|
Replying to this comment. @rufalupa666-netizen Yes, a live run of the R1-1a timing and the mixed-batch case would still help. Our runs drive the framework's scheduler and stream with a stand-in surface and a stub channel registry, and the Discord module with a stub client. A run on your host would show the same two cases with its own WebUI messages, its channels' real wakes and its gate configuration, which ours can't. The head is Written by Tabitha-2696 (Claude Opus 5.5). — Lottie-2661 |
main moved 20 merges past 4976c67. Four of them meet this branch's lines: anima-research#824 (an explicit send holds plain speech only where it may have posted), anima-research#819 (a stored message's wake is dropped once a request has read and answered it), anima-research#830 (a chat:system notice decides no channel) and anima-research#817 (a cut-off round's calls are refused). Twelve blocks conflicted, eleven in src/framework.ts and one in src/types/events.ts. Where both sides only add, each keeps both: - The start's request from a process event keeps nonChannelOrigin and the API's retake mark, with anima-research#819's readsWriteFor (its new name for readsDeferredFor). - The quiesce key keeps a private wake apart, and anima-research#819's stored message. - The start's routing keeps a private start's addressed and nonChannelOrigin, with anima-research#830's systemNotice cleared. - The XML fallback's filter refuses anima-research#817's cut-off calls and a private turn's channel_publish with no channel. - A script's wake carries readsWriteFor and the turn it continues. - ToolResult.refused's doc lists anima-research#817's cut-off case and a private turn's channel_publish. anima-research#824 moved deliverHybridProse's loop body into deliverHybridEnvelope, one envelope per call, and added routeSilencedHybrid for a round with a send. This branch's three hybrid rules go back into both: - deliverHybridEnvelope keeps a private turn's speech with no destination private before main's hold of a failed envelope's words, sets a target through pinTurn, which sets none in a private turn, and logs a private turn's empty-bodied target as setting none. - routeSilencedHybrid passes "not private" as allowLocus where it delivers a segment no send held, as every other caller does, and sets a target through pinTurn. - A locus-mode round with a send that holds nothing, and a tool-call turn's trailing speech, go to the private branch after main's held branch; at turn end a private turn's speech that anima-research#817's private think kept stays main's. No rule gives way to another: in a private turn, a segment beside a send whose place is unknown is held as a draft, as a send-silenced one was, and one beside sends whose places are known is kept private. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s drop in play main's anima-research#819 drops a stored message's wake once a request that carried the message has been answered and, for a wake with a channel, the agent has since published into that channel. This branch's tests of a room's wake that waits on a private turn's continuation use a bare wake, which names no stored message, so they never reached that rule. The new cases store the room's message during the turn's tool call and queue its wake naming it. Behind a private turn's budget restart, which reads the message and publishes into no channel, the wake still starts a turn of its own, and that turn speaks in no channel and names the room, once. Behind a channel turn's restart, which answers in the room, the wake is dropped, as on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's cut-off-calls tests (anima-research#817) wake the agent with an external-message that names no channel, which this branch makes a private surface, so a round's words publish nowhere and 29 of the file's 35 tests failed. The trigger now names world:commons, as the branch's F1 does for main's other tests, and the file passes 35 of 35. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's anima-research#824 holds plain speech beside an explicit send where the send may have posted to that speech's own target, and, beside a send whose place the framework can't read, whatever its target. A private turn's unprefixed speech has no target, so a send whose place is known holds none of it, and it is kept private, where before the merge any send held it as drafts. Beside a send whose place the framework can't read, it is still held as drafts. The test that pinned "held as drafts beside an explicit send" for a private turn is turned round into these two cases, in locus and hybrid mode: beside a channel_publish, whose place is the framework's own, the words are kept private and nothing is held; beside a send whose answer names no channel, both segments are held as drafts. A third case pins the place anima-research#824 added where a send holds a hybrid envelope: a private turn's `>>>` envelope the send held is a draft and sets no target, so plain speech in the next round stays private rather than following it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's anima-research#824 sends a hybrid envelope whose target doesn't resolve, beside sends whose places are all known, as it would go with no send, where before the send held it as a draft. In a private turn that is a bounce, and a private turn's bounce holds no draft. The new case puts such an envelope beside a channel_publish in a private hybrid turn: only the send goes out, nothing is held, and the bounce notice says the words were kept private. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ate turn main's anima-research#824 holds plain speech beside a send only where the send may have posted, and anima-research#819 drops a stored message's wake once a request that read it has been answered. The changed fragment said that words a send silenced are held as drafts, as in any turn; it now says that in locus and hybrid mode a private turn's unprefixed speech, which has no target, is held only beside a send whose place the framework can't read, and kept private beside sends whose places it reads. The breaking fragment said the [routing] notice names the channels whose messages the private turn holds. A message whose wake anima-research#819 drops once the turn has read and answered it, as an ambient or system message's is, starts no turn, so the notice may not name its channel: a run of an ambient and a system message stored during a private turn's tool call, whose wakes waited behind its budget restart, showed both wakes dropped and no notice. On main such a message gets no reply of its own either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Beside a send whose answer names no channel the framework reads, main's anima-research#824 holds plain speech whatever its target (sendHoldAt returns a hold for an unknown send before it looks at the target). In a private turn that held the unprefixed speech, which was going nowhere, as drafts, and held as a draft an envelope that names no destination or a channel that doesn't resolve, where beside a send whose place is known it bounces with no draft. So whether a private turn's bounce held a draft turned on what another call in its round answered, and a draft of private words is the resend step this change keeps out of a private turn. sendHoldAt now takes the turn's privacy, and in a private turn a null target takes no hold: unprefixed speech is kept private, and an envelope with no destination, or one that doesn't resolve, bounces as a private turn's bounce does. Every caller passes it: routeSilencedHybrid's three uses, the locus round, the text-only turn and the trailing speech. An envelope that names a channel still has a target, so beside a send that may have posted there, an unknown one included, it is held as before. Tests: the case that held a private turn's words beside an unreadable send as drafts is turned round to kept private, in locus and hybrid mode, beside a channel turn's case, whose words are held, as on main. A private hybrid turn's envelope beside an unreadable send keeps its words private with no draft when it names no destination or a channel that doesn't resolve, and one that names a channel is held. The changed fragment says what the code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved three merges past 2021d49 while this round was built: anima-research#791 (the default error policy's retry ceilings, and a thrown retryable failure taken as the stream's error event), anima-research#836 (membrane ^0.8.0 and context-manager ^0.23.0) and anima-research#839 (HostModeStatus.gatedRequests counts held wakes). Git merged them with no conflict. By our reading of their changes, none of them changes where a turn's speech goes or what makes a turn private. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's anima-research#843 (F524) carries a turn's explicit-send hold into the starts that continue the turn: the error policy's retries, a provider hold's retry and a context-budget restart. Four blocks conflicted in src/framework.ts, each where both sides add: - The request marks keep anima-research#843's TURN_CARRY beside this branch's wake channels, the turns a request continues and the retake mark. - A fresh turn's routing keeps this branch's nonChannelOrigin and privateSuppressed beside anima-research#843's heard. - A context-budget restart's request carries the turn it continues, and anima-research#843 adds the stream's carry around it (withTurnCarry copies the request with a spread, which keeps its symbol marks). - The text-only turn's hold keeps this branch's privateTurn argument to sendHoldAt, with anima-research#843's comment. A carried hold reaches sendHoldAt like any other, so in a private turn speech with no target still takes no hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its words private main's anima-research#843 carries a turn's explicit-send hold into the starts that continue it, a context-budget restart among them. The carried hold reaches sendHoldAt like any other, so in a private turn the restart's speech, which has no target, takes no hold beside a send whose place the framework can't read. The new case runs a budget restart after such a send: a private turn's restart words are kept private and nothing is held; a channel turn's are held as a draft, as on main, which shows the carry is in play. Restoring the null-target hold fails it, with the four other cases of that rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nouncement Two claims the description makes rested on probes only. E1: a stored room message tagged chat:ambient or chat:system, whose wake waits behind a private turn's budget restart, starts no turn once the restart has answered (main's anima-research#819 and anima-research#830 drop such a wake with no publish in its channel), reaches no channel, and no notice names its room. The untagged case, whose wake starts a private turn that names the room once, is the E1 test before these. B5: in explicit mode a turn that wrote a >> target and then calls sleep announces nowhere when it is private, from its start or from a tool boundary, since a private turn's envelope sets no target and a turn that turns private loses the one it had. A channel turn announces at its target, the control. Each private case goes through the code that keeps the target away: letting pinTurn set a target in a private turn fails the first, and keeping the target when the turn turns private fails the second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved thirteen merges past 8ff287d. anima-research#858 (skip_reply names the heartbeat's earlier wake) conflicted first, and the scratch merge of all thirteen gave ten blocks, nine in src/framework.ts and one in test/present-while-acting.test.ts. Each keeps both sides: - The imports keep anima-research#852's supplied-text helpers and notice tags beside readInboundSource. - The request marks keep anima-research#846's REARMED beside the wake channels, the turns a request continues and the retake mark, with anima-research#846's helpers after them. - The delivered receipt and the no-channel routing notice keep this branch's text inside anima-research#852's NOTICE_TAG. main's side of the routing notice was its July wording, which this branch's replaced. - skip_reply's self-wake keeps anima-research#858's armedAt and earlierWake, and still passes the turn that armed it to armSelfWake. - A status's locus keeps a private turn's null, with anima-research#852's suppliedValue for a channel's id. - The MCPL tool-list notice keeps its notice variable, which FRAMEWORK_NOTICES marks, with anima-research#852's suppliedValue for each tool's name. - dispatchChannelToolCall takes anima-research#858's earlierWake, under this branch's one-sentence comment. - channel_open's result puts its routing word first, as anima-research#852 does so a result cut for its length keeps it, with this branch's private-turn wording. - The test file keeps main's "channel_open's word ... survives a result cut" beside this branch's private channel_open tests.
main's anima-research#846 sends a turn again after 2, 8 and 32 minutes when its retries run out on a transient failure, as a copy of the turn's trigger. A turn that private input made private at a tool boundary keeps its trigger, the room's wake, so when the operator's message woke nothing, which leaves no private wake queued for the re-send to merge with, the copy started a channel turn and answered the private input in the room. The copy now carries the turn it continues, as every request a turn derives does (continuesTurns), so its start is judged by that turn as it stands: a private turn's re-send speaks in no channel, and a channel turn's goes where anima-research#846 sends it. Tests (turn-trigger-provenance), each a turn whose stream makes a tool call and then fails with a server error, with no retries and one re-send: - private at a tool boundary by an operator message that wakes nothing: sent again in no channel; it answered in the room before this change; - the same with a message that wakes the agent, whose own wake the re-send merges with: the merged start stays private; - opened by a private surface: sent again in no channel; - a channel turn: sent again in its channel, as on main; - a room message that comes while the turn waits starts it at once, as anima-research#846 does: for a private turn it gets no reply in the room, which only the re-send's own mark gives, since the room's wake is a channel's; for a channel turn it's answered in the room, as on main. surfaceRig takes create options for the error policy and the re-send delays. The changed fragment names the re-send among what a private turn causes.
…es do main's anima-research#852 passes every value a server supplied through suppliedValue before a notice shows it, so a line break or a notice opening in it can't pose as a framework notice. The notice that names the channels a private turn holds messages from fell back to a wake's raw channelId where the id names no one registered channel, so an id holding a line break and a "[routing]" opening put a second, bare routing notice line into it. It now shows that id through suppliedValue. This branch's three notices also write their tags through NOTICE_TAG, as main's do since anima-research#852: the two routing notices and the kept-private prose notice. The bytes are the same. Test (turn-trigger-provenance): a private start that takes in a wake for a channel no server registered names it on one line, with its notice openings marked. The rig's stand-in registry names no channel for that id, as the real one does for an id no server registered. It fails at this commit's parent with the raw id in the notice.
|
Live runs at Run 1 — private message at a tool boundary after the room's reply. 18:55:20 a mention in the room starts a turn; Run 2 — R1-1a timing: the operator's message arrives during the channel turn's tool call. 19:25:18 a mention starts a turn; Run 3 — the mixed-batch case, by hand. The mention and the WebUI message were sent within about a second of each other, the mention first. The mention's turn had already started when the WebUI message arrived (the scheduler takes the queue every pass): 19:37:32 explicit send of "batch" to the room, 19:37:41 the WebUI message lands at the next boundary and makes the turn private, "Wednesday." to the operator only, Three things we noticed:
Happy to run anything else at a later head; the host stays on this build until you say. |
|
Replying to this comment. @rufalupa666-netizen On your first point: in
Written by Lottie-2661. — Lottie-2661 |
main merged anima-research#861, which gives a heartbeat's turn no channel and holds its plain speech as drafts, and then released 0.29.0. anima-research#861 conflicted in eight blocks: six in src/framework.ts, one in src/types/agent.ts and one in test/prose-drafts.test.ts. Each keeps both sides, and where the two rules meet, a heartbeat's turn that takes in private input, this branch's rule holds: a private turn's words are kept private and never drafted. - TurnRouting keeps anima-research#861's heartbeat field beside this branch's locus wording. Its doc no longer says a heartbeat turn's plain speech is held as a draft in every routing mode, which a private heartbeat turn makes false. - A quiesce's key keeps this branch's mark for a private wake and anima-research#861's for a heartbeat's. - A ' !' continuation and a bounce's wake carry the turn they continue, as this branch's derived requests do, and anima-research#861's heartbeat mark, as main's auto-continuation already does after the merge. - At a start, anima-research#861's notice that a heartbeat turn's words are held as drafts goes only to a heartbeat turn that isn't private from that start. One that is gets what any private turn's start gets: the private-turn notice when stored private input made it private, or, opened by a private wake, the locus announcement of no channel when that changes. - sleep's announcement isn't posted in a private turn, as before, or in a heartbeat's turn with no channel, as on main. - InferenceRequest.channelId's doc names both: a heartbeat's turn holds its plain speech as drafts unless private input makes it private, and a private one takes no channel. - prose-drafts' turn helper takes both 'private' and 'heartbeat'. Every place that delivers plain speech already checks a private turn before anima-research#861's heartbeat hold, so a heartbeat turn that holds private input keeps its words private there with no further change.
…ivate Since the merge of main's anima-research#861, a heartbeat's turn holds its plain speech as drafts. Where such a turn takes in private input, this branch's rule holds instead: a private turn's words are kept private and never drafted, since a draft is one resend away from a channel. Tests (prose-drafts), in locus, hybrid and explicit mode: - a heartbeat batched with an operator's private wake, and one whose start reads an operator's stored message, keep their words private, with no draft and no notice that they are held; beside them, a heartbeat with no private input holds its words as a draft, and in locus and hybrid mode is told so, as on main; - a heartbeat turn that an operator's message makes private at a tool boundary keeps the words after it private, with no draft; beside it, a heartbeat turn with no private input holds its words before and after the call as drafts, as on main. The changed fragment says a heartbeat's turn that takes in private input is private too, where 0.29.0 holds a heartbeat turn's words as drafts.
main has taken anima-research#546, Dendrite (derived agents, the admission stage, tune-out reader forks, the all-except hold), and released 0.30.0, whose speech.locus default is 'delivery': a turn with no channel of its own takes no fallback, except the excepted channel while an all-except hold is open, and under 'latest-inbound' the latest inbound channel after it. The conflicts, 18 blocks in five files with diff3: - Unions: the imports; HOST_METADATA_KEYS ('callerTurnId' beside 'dendrite'); freshTurnRouting; an ephemeral run's end (the private-input mark beside forgetAnnouncedLocus); the boundary (the private-input check, then main's addressed notice under 'delivery'); a round's call filter and roundCallOf (main's mayCallTool, with the private publish refusal); a private turn's channel_open (main's rememberAnnouncedLocus); the tune-out coordinator's requestInference hook and cancel's parameters (callerTurnId after main's noteAuthor and nextState); two test blocks. - Re-applied to code main restructured: - A fork's disposal goes through main's releaseAgentRuntime, so the private-input mark is cleared there, on every path an agent ends by, and in the branch with no live agent. - dispatchChannelToolCall: the private publish refusal first, then a fork's home from main's homeOf. - A later start (retry, restart, continuation): a private turn names its held arrivals' channels; any other turn takes main's rule, a re-pin under 'latest-inbound' or the addressed-elsewhere notice under 'delivery'. - handleTuneOutTool no longer receives the call, so the turn a cancel's wake carries (workTurnOf) is passed in from both dispatch sites. - channel_publish's description and channelId: main's rewrite, with the sentence that a turn that has taken in private input, and a script it started, must name a channel. The fold: the private turn's notice of channel messages and main's addressed-elsewhere notice share one set, TurnRouting.addressedElsewhere, so a channel is named once in a turn, by whichever notice gets there first. At a tool boundary the private turn's notice runs under both rules, after main's addressed notice under 'delivery', so an unaddressed follow-up where the turn sent is named there too. Tests changed for main: - A hand-built turn routing carries addressedElsewhere, as every one has. - A fork's home is set on its registry record, since main keeps homes there. - Under 'delivery', a private turn's boundary names an addressed room's message by main's notice, once; and a room turn's restart stays in its channel, as main's rule has it. - The tune-out cancel's wake test runs under 'latest-inbound', where a channel-less turn has a fallback, so its private arm can fail. - main's new skip_reply dispatch-refusal test names its channel, as this branch's other tests in that file do, since a message naming no channel is a private surface's here. - main's baseline-restart tests read this branch's no-channel notice.
… across a restart main now stores each agent's announced locus (rememberAnnouncedLocus), so the routing announcement after a restart compares with what the agent was last told before it. The private-turn notice tells the agent its plain speech goes to no channel, and this branch set the baseline to none in memory only: after a restart the stored baseline was still the channel announced before, and the next turn in that channel wasn't told of it. The notice now records its baseline as main's announcements do. Test (routing-baseline-restart): a room turn, then a room turn a stored operator message makes private, then a restart and a room turn, which is told its channel again.
…ts wake private Dendrite's all-except hold (focus) is new on main, and two of its paths reached a private turn without anima-research#190's rules: - Entering a focus gave an open turn with no locus the excepted channel. A private turn has its locus deleted, so it qualified, and the tool's answer told it its plain speech lands in the excepted channel. A private turn now keeps no locus, as a private turn's channel_open does, and the answer says its plain speech stays private. The answer's other sentences, and the focus and tune_out tools' descriptions, name the private-turn exception beside the no-channel one. - cancelAllExcept's release wake carried no turn, where a per-channel cancel's carries the calling turn's (workTurnOf). It now carries it, so the turn the release wakes is private when a private turn cancelled. Tests: a private turn entering a focus (all-except-e2e), the release wake under 'latest-inbound' from a private and a room turn, and, for the merge's boundary under 'delivery', a private turn told at the boundary of an unaddressed follow-up where it sent (turn-trigger-provenance).
…tely Dendrite's agents are new on main. An agent derived while its parent's turn is open reads that turn's context, an operator's private input included; a worker spawned from a turn gets its task from that turn's call; and agent mail carries one agent's words to another. Each came back to its recipient as a channel-less wake carrying no turn, so under a focus hold or 'latest-inbound' the answer took the fallback: the incident's shape, through another agent. What now counts as a private turn's work: - An agent derived from, or spawned by, an agent whose turn is open and private (createEphemeralAgent, which deriveAgent goes through, reading inherit's source and spawnedBy as it starts). The record notes it (AgentRecord.privateTurn), so it survives a restart, and a resumed job keeps it. - An ephemeral run handed mail from such work, from then on: a run's whole job is what it's handed. Residents are judged turn by turn, as before. - Mail an agent sends while its own turn is open and private (sendAgentMessage). Mail from such work (a result, a message, a reader fork's report) is held and stored with the mark (HeldMail.privateTurn, the stored row's dendrite.privateTurn), and so is the restart's notice that such an agent was interrupted. isPrivateInput reads the row's mark, so the start that reads it, or a boundary that takes it in, makes its turn private, as an operator's message does. The framework's tune-out reader fork is derived the same way. The mark is by timing: a host deriving or spawning for its own reason while a private turn is open gets a result its agent answers privately. Only the framework writes the mark. A module's two write paths, a process response's addMessages and addModuleMessage, drop dendrite.privateTurn as they drop callerTurnId (withoutPrivateWorkMarks, renamed from withoutCallerTurn), and leave the rest of a module's dendrite metadata as main does; a server's copy drops dendrite whole, as on main. Tests (turn-trigger-provenance, dendrite-lifecycle): a derived and a spawned agent's result after a private and a room's turn; mail sent from a private and a room's turn; a worker spawned outside any turn and handed mail from a private and a room's turn; and the restart's interrupted notice for an agent derived in a private and in another turn. The test of a module's message that carries a private turn's key runs again with the mark in its place.
… is answered in no channel The incident's shape on 0.30.0: with speech.locus 'delivery' a turn with no channel of its own takes no fallback, except the excepted channel while a focus holds; under 'latest-inbound' it takes the latest inbound channel. An operator's message from a private surface opens such a turn. Through the real channel registry, its answer reaches no channel in either case, and a room turn after it still speaks, so the absence isn't a send still on its way. On main both answers are posted, in the excepted and the latest inbound channel.
0.30.0 changed what a turn with no channel of its own does: under the new default 'delivery' it takes no fallback, except an open all-except hold's excepted channel, and 'latest-inbound' brings back the most recent inbound channel after it. The fragments said what this branch changes against the old default. They now say it against 0.30.0: a private surface's turn takes neither fallback; a boundary's message that addressed the agent or follows up where the turn sent is named, with 0.30.0's own addressed notice naming a channel first under 'delivery'. They also name what this branch does on Dendrite's paths: an all-except hold a private turn enters or cancels; an agent derived or spawned while a private turn is open, a run handed mail from such work, and mail sent from a private turn, with what that does to a resident's subagents; and the crash-restored tune-out release, which is unchanged.
Under 0.30.0's default speech.locus 'delivery', a turn with no channel of its own takes no fallback, so a private turn's plain words go nowhere with or without the parts these tests cover. Those parts still matter where a fallback exists: while an all-except tune-out holds, and under 'latest-inbound'. These tests run on stand-in registries that give every channel-less turn a channel, which is that condition, and each fails when its part is removed. Their names now say so, ending "even where a fallback exists (a focus hold or 'latest-inbound')", so a reader working under 'delivery' doesn't take them for tests that can't fail. Titles only; no assertion changes.
main took ten pull requests after 0.30.0 (anima-research#866, anima-research#864, anima-research#873, anima-research#874, anima-research#877, anima-research#872, anima-research#881, anima-research#875, anima-research#378, anima-research#876). Two places conflicted, and both keep both sides: - src/gate/event-gate.ts: this branch's sleepTurnId field, with main's rewritten note on dispose(), since anima-research#881 removed resumeSleep(). This branch never called resumeSleep(). - test/turn-trigger-provenance.test.ts: this branch's imports. main added an import of MembraneError, which this file already imports, with StreamEvent and YieldingStream, from the same package.
What happened in production
Three times in one week a private WebUI reply from a connectome-host resident was published to a Discord channel. The resident had not called a send tool.
In 0.30.0 (
630e2a3a07),speech.locusdefaults to'delivery', and the changelog says a turn with no locus "falls back only to an open all-except tune-out's excepted channel, and otherwise keeps its prose private". So a private surface's turn still takes a channel while a focus holds, and under'latest-inbound'. Atmain's0f11975e48, an operator's TUI message answered in plain text while a focus heldworkwas published inwork. Under'latest-inbound', after a mention innoisywas answered, it was published innoisy. Two tests intest/all-except-e2e.test.tspin those two cases, and both fail at0f11975e48(Tests, below).Scope and guarantee
This section and the next two are the author's, updated by us for the rework described under "Our part".
A turn that private input opens, or takes in, publishes none of its plain speech to a channel for the rest of the turn. Private input is a private surface's message, or what work a private turn started brings back: a script's wake message, what an agent derived or spawned during a private turn sends back, what a run handed mail from such work sends, and mail an agent sends while its own turn is private. A private surface is a sender whose messages name no channel: the WebUI, TUI, CLI, headless and API callers. Its messages are the
external-messageandapi:messageevents whosemetadata.channelIdnames no channel, apart from the framework's own MCPL tool-list notice, anexternal-messageno operator wrote. Explicit send tools and explicit prose envelopes still reach a channel the agent names. While a turn is private, an envelope's body goes where it names, but the envelope sets no sticky destination, so later plain speech stays on the surface, as the turn's notice says."Takes in" covers three of the four timings R1-1 named, R1-1a to R1-1c below. One rule decides each of them: private input makes the turn private, whatever else its start, batch or tool boundary holds, and nothing later in the turn makes it public again.
main's fix(turns): handle a stream cancel() that throws; start a continuation without the wakes batched with it #762, a budget restart takes in no wake. A private message stored during the turn makes the budget restart private, as the first start to read it, and the message's own wake starts a private turn after it.resume().A turn that becomes private loses its locus, its trigger channel and its sticky
>>or>>>destination, and, unless it's a silent heartbeat tick with no speech to place, a[routing]notice tells the agent why: private input came in, an operator's message or what a private turn's work brought back. It doesn't become a channel's again. A round written before the turn became private, and delivered after it, still posts where its envelopes name.R1-1d, content already read, stays out. What's private is the turn, not the operator's words: once a start has read an operator's message, a later turn isn't private for it, though its context still holds the message, unless that turn is an operator's retake (D4 below). Neither is another resident reading the same message slot. That is #256's work: its speech routes keep a turn's route with its content.
Current architecture
Line numbers in this description are
src/framework.ts's at this head.isNonChannelSurfaceEventsays whether an event is a private surface's. The framework'sinboundSourcesurface stamp now follows the same rule, so it stamps a stored message as a surface's exactly whenisNonChannelSurfaceEventcounts it as a private surface's.InferenceRequest.nonChannelOriginmarks a private wake, by the direct path and by the gate's debounced wake (WakeProvenance.nonChannelOrigin, which then names no route). A request that continues a turn's work carries that turn's id instead (CALLER_TURNS): its budget restart, its tool results' re-inference, a!continuation, its auto-continuation, a bounce's wake, a rewind's retry, its re-send after its retries run out (feat(turns): send a turn's trigger again when its retries run out on a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits #846), and the wakes below. An operator'snudgeorunstick, or the API'sinference.request, carries a mark of its own (RETAKE) instead, judged where its start reads the context (below). A start that makes its turn private marks its own wake too, so a provider hold's copy of it for the turn's retry carries the mark.0, which counts as private.wake_agent()and a script left running reporting its end, by the start turn the isolate records; a self-wake armed withskip_reply'swake_in_seconds, and asleep's wake, by the turn that armed them; and the resident's turn after a tune-out cancel it asked for, the end of a focus included (src/tune-out/coordinator.ts:1122).isPrivateInput). That covers a script's wake message, which carries its start turn underCALLER_TURN_KEY, and what a private turn's Dendrite work sends. An agent derived from, or spawned by, an agent whose turn is open and private (:10815), and an ephemeral run handed mail from such work (:11681), are marked on their record (AgentRecord.privateTurn), and a resumed job keeps the mark. Their results and messages, the notice that a restart interrupted them, and mail an agent sends while its own turn is open and private (sentFromPrivateWork,:11650), a reader fork's report included, are stored withdendrite.privateTurn, whichisPrivateInputreads (:29947). A turn that reads such a message is private, and a tool boundary that injects it makes its turn private from there. Only the framework writes the key and the mark: a server's copy dropsdendritewhole, as on 0.30.0, andcallerTurnIdwith it, and a module's two write paths drop both (withoutPrivateWorkMarks,:424).some()), continuations included. Judging there, where the work is read, counts a turn that turned private after the request was queued. The quiesce key and a provider hold's dedup keep a private wake apart from others, and wakes merged there or into a start keep the turns they continue (carryWakes), so merging never drops either.mainnow starts a turn's continuation alone (fix(turns): handle a stream cancel() that throws; start a continuation without the wakes batched with it #762), a budget restart or a tool-result guard's retry: the requests that waited in its batch go back to the queue and start a turn of their own after it. Each of them first takes the open turn's id as a turn it continues (continuesTurns) and keeps its own marks (markWaitedOnTurn,src/framework.ts:15723–15726). So by the rule above, its start is private when that turn is private then, or when the request is a private wake itself. A quiesce that parks the requests beside a continuation marks them the same way.notePrivateInput). Its next start that can publish is private. The mark is spent once that start's stream begins, so a silent heartbeat start, or a start that parks or ends before its stream, leaves it to the next.isPrivateInput): a surface's stamp, or a private turn's id. It readsgetAllMessages(), the cached view the turn's compile reads next, so input the context now shows only in a summary still counts, and input that was hidden, shed or removed, or is on another branch, doesn't. Nothing is kept about the retake between turns.TurnRouting.nonChannelOriginis set when a private start opens the turn.makeTurnPrivatesets it when a start that continues the turn takes in a private wake or stored private input, or when a boundary injects private input. Once set, it stays set for the turn. A private turn's start routes to no channel and is addressed in none, whichever wakes joined it. Its telemetry still says who woke it.pinTurn). That includes an envelope that a send held as a draft, and one in a round that was queued before the turn became private and delivered after it on the speech chain.privateSuppressed) and says it went to no channel. Without that it would reach routing with no locus and be reported as a failed send. In explicit mode it isn't bounced into a draft to resend: a notice says it was kept private and how to publish (keepPrivateTurnProse). Nor is an envelope that bounces in a private turn, in hybrid and explicit mode, because it names no destination, names a channel that doesn't resolve, or its delivery fails, words it adds to an{{unsent}}resend included: the bounce notice says the words were kept private, or, when the send threw, that they may have gone out. Only a bare{{unsent}}resend keeps the draft it sent. Where an earlier draft is held as the latest bounce, either notice says{{unsent}}still names that draft, not these words. Words a send holds back are held as drafts, as in any turn. In locus and hybrid mode a send holds back speech going where it may have posted, or, when the framework can't read where it posted, speech going anywhere (fix(prose): an explicit send holds plain speech only where it may have posted #824). A private turn's unprefixed speech, and an envelope of its that names no destination or names a channel that doesn't resolve, have no destination, so no send holds them back (sendHoldAt'sprivateTurn).channel_publishwithoutchannelIdis refused at the model's own dispatch, judged by the turn the call was made in as it stands, a conversation fork's included, and its round doesn't count the refused call as a send. The refusal answers as the dispatcher's other refusals do, so its result carriesrefused(ToolResult.refused), and the tool-result guard's notice for a withheld one says the tool didn't run. A script'schannel_publishthat names no channel, an emptychannelIdincluded, is refused when the turn that started the script is private as it stands at the call. Otherwise it goes where 0.30.0 sends it.[routing]notice: at a start that takes in a channel's wake (a batch's, a gate debounce window's channel messages, or a wake that continues the open turn), at a live tool boundary that injects a message that addressed the agent or follows up where the turn sent, and at the turn's next start for such messages it stored with no live stream to tell, those noted before it turned private included. Each channel is named once in the turn, by this notice or by 0.30.0's notice that someone addressed the agent from another channel, whichever comes first, since both read and write 0.30.0's record of the channels named (TurnRouting.addressedElsewhere,:21704). Under'delivery', a private turn's tool boundary runs 0.30.0's notice, then this one (:16705–16709). Under'latest-inbound', this one runs where 0.30.0 re-pins. In explicit and disabled mode no channel's message moves where plain speech goes, in any turn, so no channel is named.channel_openopens the channel for incoming traffic and moves neither the locus nor the trigger channel. Nor does its entry into an all-except hold (focus, ortune_outwithscope: 'all-except'), which on 0.30.0 gives an open turn with no locus the excepted channel (:36960), and the hold's answer says the turn's plain speech stays private. Its.sleepannouncement isn't posted: outside explicit mode it has no locus, and in explicit mode, where the announcement goes to the turn's>>target, a private turn has none, even after an envelope of its names a channel. When the model refuses (a refusal stop), a private turn's refusal reacts on no message, and its failure record names no channel.rememberAnnouncedLocus). A private turn's notice that its speech now reaches no channel is stored the same way (:21745), so after a restart the agent's next room turn is told its channel again, as it is without a restart.SpeechContext.nonChannelOrigintells speech handlers a private turn's speech is private, and the built-in Discord module posts none of it.Requirements, and the tests that pin each
Each requirement says what someone meets, and its tests assert that: what reaches a channel (every
routeSpeechanddeliverSpeechcall the registry gets, a call with no locus included), what the resident is told, a tool's result, typing, or what modules hear. Tests are named by their titles, mostly inturn-trigger-provenance, and a title cut short is quoted with "…".turn-trigger-provenanceandexplicit-prose-routingrun on stand-in channel registries that give a turn with no channel a fallback channel (test/turn-trigger-provenance.test.ts:414;test/explicit-prose-routing.test.ts:128and:2320), as a focus hold and'latest-inbound'do on 0.30.0. A test whose rule changes what happens only where such a fallback exists has a title ending ", even where a fallback exists (a focus hold or 'latest-inbound')", cut here.metadata.channelIdnames no channel is private, a source added later included; one that names a channel routes as on 0.30.0. "treats an unknown future external-message source as private", "a WebUI turn after Discord traffic freezes null and keeps prose private", "a WebUI turn overrides even a resident home channel", "an API message that names no channel opens a private turn", andmain's tests whose triggers now name their channel.ingress-host-keysfor a server's copy.held-turn-retry), the two incident tests inall-except-e2e, "under 'delivery', an operator's message while a focus holds is answered in no channel" and "under 'latest-inbound', an operator's message after a room was answered is answered in no channel", and every test below that asserts nothing was routed.routing-baseline-restart, "a channel turn made private is what the agent was last told, after a restart too". No false[discord-send-failed]can be stored: that marker comes only from a failed route, and the routed assertions above see every call the framework makes.channel_publishwith no channel is refused, the model's by its own turn, as a refusal (refused), and a script's by its start turn, and the description says so. "a private turn's channel_publish that names no channel is refused, and says how to name one", a conversation fork's included, "a channel_publish that names no channel is judged by the turn it was made in…", "a script's channel_publish that names no channel is refused in a private turn…", "a script's refusal in a private turn points to channel_list only for an agent that may call it", "a script left running past a private turn's end is refused…", "channel_publish's description says…", and the round's accounting: "…counts its channel_publish that names no channel as no send…", "…is no send on the fallback path either…", "XML tool mode: a private turn's refused channel_publish silences nothing…".discord-thoughts-own-turn), "a private turn's speech reaches modules marked private", "a turn that turns private at a boundary shows its speech handlers no channel…", and inall-except-e2e, "a private turn that enters a focus keeps no channel, and is told its plain speech stays private".channel_publishwith no channel is refused, where the same start with no private input doesn't refuse it), "a budget restart that reads a stored private message is told so, unless it's silent", "a silent turn's boundary that stores private input…", "a boundary that ends the turn leaves it a channel turn…".tune-out-e2e, "under 'latest-inbound', the resident's turn after its own tune_out cancel" "speaks in no channel when a private turn cancelled"; and inall-except-e2e, "under 'latest-inbound', the turn a focus cancel wakes speaks in no channel when a private turn cancelled"; each beside its channel-turn case, which speaks as on 0.30.0; and "a script's wake queued while its turn was a channel's speaks in no channel once that turn turns private first…".nudge,unstick, or the API'sinference.request) is private exactly when the agent's stored history holds private input as it starts, on the branch the context is on then, however the context came to end there. With none, it speaks as on 0.30.0. "a nudge after a private turn speaks in no channel…", "a nudge after a channel turn speaks as on main, in the fallback channel", "an unstick after a private turn speaks in no channel…", "an inference request through the API after a private turn speaks in no channel…" (and after a channel turn, as on 0.30.0), "a nudge asked for while a channel turn runs, which then takes in private input, speaks in no channel…", "a nudge after the host's own undo, twice, back to a private question speaks in no channel…" (connectome-host's/undo:cm.branchAtandcm.switchBranch), "a nudge after a channel turn that followed a private exchange speaks in no channel…", "a nudge after the host's own undo, twice, back past a private exchange speaks as on main, in the fallback channel", "an unstick after the operator hides messages, in a context that never held private input, speaks as on main, in the fallback channel", "after a restart, a nudge following a channel turn speaks as on main, in the fallback channel" and "…following a private turn speaks in no channel…", "a nudge after a channel turn and a silent heartbeat tick that took in a stored private message speaks in no channel", "a nudge parked behind an auxiliary call, while the host removes the private exchange before it, speaks as on main, in the fallback channel", "a nudge merged with a channel's wake after a private turn speaks in no channel…".dendrite-lifecycle, "a fork a restart interrupted wakes its parent into a private turn, when it was derived while a private turn was open", beside "…into an ordinary turn, when it was derived in another turn".turn-continuation, "starts a wake held ahead of a private turn's guard retry as a private turn of its own"; "a private start that takes in a room's message names the room before the reply…", "a room's turn made private by a stored operator message names the room it was woken for", "a room's wake that continues an open private turn names the room", "a channel's message is named once in a private turn…", "a private turn's boundary that injects a room's addressed message names the room…", "under 'delivery', a private turn's boundary that injects an unaddressed follow-up where the turn sent names the room with the injections", "a private (or room's) turn's boundary over budget…", "a gate wake names its window's channel messages…", "an addressed arrival … is named at the next start…", "an arrival removed before the private turn's next start isn't named", "a private start that takes in a wake for a channel no server registered names it on one line, with its notice openings marked", and the explicit and disabled mode starts above. A room message that comes while a turn waits to be sent again (feat(turns): send a turn's trigger again when its retries run out on a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits #846): "a room message that comes while a private turn waits to be sent again starts it at once, and gets no reply in the room", beside "a room message that comes while a channel turn waits to be sent again starts it at once, and is answered in the room, as on main".main's tests inagent-allowed-tools,code-execution,cut-off-calls,explicit-prose-routing,outgoing-stream,present-while-acting,tool-input-unparseable,tool-result-guardandtool-wrapper-prose-guard-integration, whose triggers now name their channel, and each parameterized test's channel-turn case.Co-developed with qa-engineer, the connectome-host resident that hit this.
Our part
Where this differs from the R1-1 fix in Anarchid's first review. Its point 3 kept requeueing channel requests so they still get their own turn. This head takes a channel request that meets private input into the private turn instead, or, where
mainnow starts the turn's continuation alone, into a private turn of its own after it. An ambient or system message's wake there starts no turn: it's dropped once the continuation has read and answered the message (#819). In locus and hybrid mode, a[routing]notice tells the resident which channels' messages the turn holds before it replies, so it can answer the room with a send, and the turn's receipt says its speech was kept private. Requeueing the request would bring back the gate window's parts and the ordering between the two kinds of turn that running them as separate turns needed. That machinery was most of what this pull request's earlier rounds added, and still let the room's turn read the operator's words.#789, a module's request that can't say whose turn's work it continues, stays open: this change gives a module's request and message no turn, as 0.30.0 gives them none.
Against
mainat0f11975e48, the change is +695 −243 lines insrcand +3,461 −84 intest. Of thesrclines it adds, by each line's first characters, 589 are code, 70 are comment lines and 36 are blank.Where
main's recent changes meet this one (merged at2021d49d, then at36f27982, which merged with no conflict, then at8ff287dd, then ate85c57db, then at62a126c7, then at630e2a3a07, and then at0f11975e48). In a round that calls the drafts tool (#716), an unprefixed line written while a channel turn has no destination is held as a draft, as on 0.30.0, and a private turn's is kept private, never drafted. A continuation that now starts alone (#762) is the case under E1 above. A private turn's refusedchannel_publishcarriesrefused, so the guard's withheld notice (#810) says the tool didn't run. In locus and hybrid mode, an explicit send now holds back plain speech going where it may have posted, or, when the framework can't read where it posted, speech going anywhere (#824). A private turn's unprefixed speech, and an envelope of its that names no destination or names a channel that doesn't resolve, have no destination, so no send holds them back: they are kept private beside any send, and such an envelope bounces as a private turn's bounce does, with no draft. Where #824 delivers a hybrid segment no send held, the private turn's rule applies, as at every other hybrid delivery, and an envelope a send held sets no target in a private turn. #819 drops a stored message's wake once a request that read the message has been answered, for the kinds its changelog names: channel messages other than a conversation fork's, push events, a module event's one message, background-script wakes and the gate's self-wake notices. A private surface's message isn't one of them, so its wake isn't dropped, as on 0.30.0 (src/framework.ts:17111). For a wake that carries a channel, the agent must also have published into that channel since, unless the message's server tagged it ambient (chat:ambient) or system (chat:system, #830's notices). A private turn publishes into no channel unless it names one, so a room message's wake that waited on a private turn's continuation still starts a turn of its own, which speaks in no channel. An ambient or system message's wake needs no publish, so once a continuation has read and answered the message, its wake is dropped and starts no turn, as "a stored room message tagged ambient, whose wake waits for a private turn's budget restart, starts no turn once the restart has answered, reaches no channel, and isn't named" asserts, and the test whose title names a message tagged system asserts the same. On 0.30.0 such a message gets no reply of its own either. A private turn whose start takes in such a message's wake names the message's channel, as "a gate wake names its window's channel messages…" asserts for an ambient one. #843 carries an explicit send's hold into the starts that continue a turn: the error policy's retries, a provider hold's retry and a context-budget restart. A carried hold goes through the samesendHoldAtas a send's own (src/framework.ts:1679), so a private turn's speech with no destination still takes no hold: after a send whose place the framework can't read, a private turn's budget restart keeps its words private, where a channel turn's are held. #817's refusal of a cut-off round's calls and #830's notice mark are next to lines this change touches, and each is kept as 0.30.0 has it. #846 sends a persistent agent's turn again when its retries run out on a transient failure, after 2, 8 and 32 minutes (src/framework.ts:862), as a copy of the turn's trigger (:6565). Here that copy also carries the exhausted turn's id, as every other request a turn derives does, so its start is judged by that turn: a private turn's re-send speaks in no channel, and a channel turn's goes where #846 sends it. With these tests and without that change, a turn made private at a tool boundary by an operator's message that woke nothing kept the room's wake as its trigger, and its re-send answered in the room (red first, below). A room message that comes during the wait starts the turn at once, with the re-send merged in (:19478), so for a private turn that start is private and the room message gets no reply in the room. The notice that names the channels a private turn holds messages from now passes a channel id no server registered through #852'ssuppliedValue(src/framework.ts:21710). Where #858 hasskip_replyname the heartbeat's earlier wake, the merge keeps the self-wake's turn. #861 has a heartbeat's turn take no channel and hold its plain speech as a draft. Here a heartbeat's turn that takes in private input, from an operator's private wake in its batch, an operator's stored message its start reads, or one that makes it private at a tool boundary, keeps its words private with no draft, as any private turn does. One that is private from its start, from a private wake or a stored message, gets no notice that its words are held. A heartbeat's turn with no private input holds its words as a draft, as on 0.30.0.The merge of
630e2a3a07, 0.30.0 with #546's Dendrite (dd5f21c18f), and the six commits after it.'delivery'default. Under it, 0.30.0's changelog gives a turn with no locus only an open focus's channel as a fallback, and'latest-inbound'brings back the most recent inbound channel after that. So under the default, a private surface's turn on 0.30.0 already reaches no channel unless a focus holds. Some parts change what happens only where a fallback exists, and "What each part is for" below says which.TurnRouting.addressedElsewhere), which replaces this branch'schannelsNamed. In a private turn under'delivery', 0.30.0's notice names a room whose message addressed the agent at a tool boundary, and this one names an unaddressed follow-up where the turn sent (E1).src/framework.ts, 2 insrc/mcpl/channel-registry.ts, 2 insrc/tune-out/coordinator.ts, and one each intest/ingress-host-keys.test.tsandtest/tune-out-e2e.test.ts. Eleven keep both sides. Seven carry this branch's lines into code 0.30.0 restructured:releaseAgentRuntime, which drops the agent's stored-private-input mark (:11954).disposeConversationAgentcalls it when an agent is live under the name (:17890), and otherwise drops the mark itself (:17902);dispatchChannelToolCallrefuses a private turn's publish first, and takes a fork's home from 0.30.0'shomeOf;'latest-inbound'and gives its notice under'delivery';handleTuneOutTooltakes the calling turn from both dispatch sites, and its per-channel cancel passes it on (:36944);channel_publish's description andchannelId's are 0.30.0's, each with this pull request's sentence or clause added.9778fcdd94stores a private turn's notice with the routing 0.30.0 records each agent was last told;f8d1ea7f87keeps a private turn that enters a focus private, and the turn that the focus's end wakes when that private turn ends it;8c5e233696makes a private turn's derived, spawned and mailed work come back as private input, and drops the mark from what a module stores;09a83cd3caadds the two incident tests;aee5c62180rewrites the changelog fragments against 0.30.0; and566cbb1a18renames the tests whose rule changes what happens only where a fallback exists.turn-trigger-provenance, a hand-built routing getsaddressedElsewhere, a fork's home is set on its registry record, "a private turn's boundary that injects a room's addressed message names the room with the injections, and moves nothing" asserts that 0.30.0's notice names it once, and "a room's turn's boundary over budget leaves an addressed message to its restart, which stays in its channel" asserts the reply in the room.tune-out-e2e's cancel tests run under'latest-inbound'. 0.30.0's new test inagent-allowed-toolsnamesworld:commons, as this branch's other tests there do. 0.30.0's tests inrouting-baseline-restartread this branch's no-channel notice, andall-except-e2e's focus answer gains the private exception.The merge of
0f11975e48(191fc24821, this head). After630e2a3a07,maintook ten pull requests: #866, #864, #873, #874, #877, #872, #881, #875, #378 and #876. The merge conflicted in two places, and each keeps both sides: insrc/gate/event-gate.ts, this branch'ssleepTurnIdfield sits besidemain's rewritten note ondispose(), and intest/turn-trigger-provenance.test.ts, both sides' imports are kept,main'sMembraneErroron this branch's@animalabs/membraneline. #881 removedEventGate.resumeSleep(), with its call insrc/framework.tsand its tests intest/event-gate-sleep.test.ts, and at this head nothing insrcortestnames it. At this head, the test filesmainchanged since630e2a3a07and those this branch changes pass together (Tests).main's comments (R6-4). Our comment pass took out each ofmain's comments that held a sentence this change made false. The sentences that still hold are back, verbatim:sameInferenceRequest's doc, the quiesce key's reason, and "Conversation forks act only on their home channel." Each sentence that stays out is false once private input makes a turn private:resumesLogicalTurn's "takes in no new input, and keeps the turn's locus, trigger channel and tool-call count", andTurnRouting's "none of them re-decides the locus, drops a sticky destination …": a start that continues the turn with private input drops its locus, trigger channel and sticky destination.triggerChannel's "A turn that channel_opens a channel moves it there" and "Kept across the turn's streams …": a private turn's open sets nothing, andmakeTurnPrivatedeletes the trigger channel at a continuing start or a tool boundary.locus's "Frozen when the turn opens (home channel for a fork, else the trigger channel, else the global default) …": a private turn opens with none.pin's "set by each>>(hybrid:>>>) prefix that resolves", and "Sticky within the turn …" at the explicit-mode pin: no envelope sets it while the turn is private.arrivals' "The turn's next start clears them all, and … first re-pins to the newest one still in the agent's store": a private turn's next start names them instead.makeTurnPrivateremoves it.channel_publishwithoutchannelIdis refused, not sent home.channel_open: "a channel_open's result … moves its locus while the turn is still open", "plants the agent's feet in the opened channel" and "MOVES the current turn's locus": a private turn's open moves no locus.addressedElsewhere's doc, new in 0.30.0, "Underspeech.locus: 'delivery', the channels the turn has already told the agent it was addressed from while busy …": the set now also holds the channels a private turn's notice names, under either setting, whether or not their messages addressed the agent.Sentences that hold but depend on one of those stay out with it:
arrivals' "One removed meanwhile … isn't there for the agent to answer", which explains the re-pin's check; "A message no tool boundary of the turn weighed can still move it", whose "it" is the locus a start no longer always keeps; and, abovechannel_open's result, the 2026-07-21 fix's history and the announcement sent with the tool result, whichlastAnnouncedLocusrecords, since the announcement they name is a locus move a private turn's open doesn't make.What callers notice, against 0.30.0:
'latest-inbound'the most recent inbound channel, and otherwise publishes nothing either (What happened in production).mainnow starts the turn's continuation alone (fix(turns): handle a stream cancel() that throws; start a continuation without the wakes batched with it #762), the message's wake starts a private turn of its own after it, unless it is dropped once the turn has read and answered the message, as an ambient or system message's is (fix(scheduler): drop a stored message's wake once a request has read and answered the message #819). A channel message whose start is the first to read a private message stored before it gets no inferred reply in its channel either. The private message it reads may be one that woke nothing. In locus and hybrid mode, a[routing]notice names the channels whose messages the private turn holds. The changelog files this as breaking, innon-channel-origin-locus.breaking.md.[routing]notice saying so, whatever destination it had, and loses its sticky destination. A silent heartbeat tick, which has no speech to place, gets no notice.>>destination if it has one, and otherwise bounces into a draft with an offer to resend it, as 0.30.0's owntest/explicit-prose-routing.test.tsshows for a trigger that names no channel. In hybrid and explicit mode, an envelope that bounces in a private turn holds no draft and offers no resend, where 0.30.0 holds it as a draft with that offer. Nor does a round that calls the drafts tool hold a private turn's unprefixed line, where 0.30.0 holds a line with no destination as a draft.channel_publishthat names no channel is refused when the turn that started the script is private. 0.30.0's changelog sends such a call to its turn's channel where it has one, and otherwise to the fallback: an open focus's channel, or under'latest-inbound'the most recent inbound one.channel_publish's description, andchannelId's, say a turn that has taken in private input, and a script such a turn started, must name its channel, as a subagent must. That's one sentence in the first and one clause in the second, and it changes every agent's tool list once. Thefocusandtune_outdescriptions each gain the same exception to where plain speech lands while the hold is open, which changes every resident's tool list once more.nudgeorunstick, or the API'sinference.request, is private while the agent's stored history holds private input, even right after a channel turn. On a host with a private surface beside channels, anunstickasked in a room then posts its outcome notice there, and the answer reaches no channel. On a host with no private surface, a retake speaks as on 0.30.0. Since 0.30.0 gives a turn with no channel a fallback only while a focus holds or under'latest-inbound', those are where a retake's answer differs from 0.30.0's.skip_replyself-wake, asleep's wake or a tune-out cancel's turn for the resident, a focus's end included, armed in a turn that is private by the time it comes, wakes a private turn. On 0.30.0 such a turn's plain speech takes the fallback where there is one: an open focus's channel, or under'latest-inbound'the most recent inbound channel.focus, ortune_outwithscope: 'all-except') keeps no channel, and the hold's answer says its plain speech stays private. On 0.30.0 an open turn with no channel takes the excepted channel. The answer's other sentences end with the same exception, so its wording changes for every turn.dendrite.privateTurnmark in a message a module stores is dropped, as acallerTurnIdkey is. 0.30.0 already drops a server'sdendrite.metadata.channelIdis only whitespace is stamped as a surface's:inboundSource.kindis'surface', where 0.30.0 stamps no source for it. The stamp's one-line check changes fromif (typeof metadata?.channelId === 'string' && metadata.channelId) return undefined;toif (!isNonChannelSurfaceEvent(event)) return undefined;, which counts a whitespace-only id as naming no channel. The stamp also skips the framework's own tool-list notice, since its check doesn't count the notice as a private surface's (src/framework.ts:1794), so a module that stores everyexternal-messagekeeps that notice with noinboundSource, where 0.30.0 stamps it as a surface's, with a name made ofmcpl:followed by the id of the server whose tools changed, such asmcpl:tools-server. A stamped notice would make a later turn that reads it private, and the test "a turn woken by the framework's own MCPL tool-list notice speaks as on main" fails when the stamp goes back to 0.30.0's.[routing]notice for speech with no channel says it stays on the surface the agent was addressed from, if any.SpeechContextgains an optionalnonChannelOrigin.What this leaves as 0.30.0 has it:
beforeInferencehooks are told the channel context 0.30.0 gives a turn with no channel.metadata.channelIdnames a channel) routes as 0.30.0 routes it.executeToolCallofchannel_publishas the agent is judged as 0.30.0 judges it.inference-requestand the messages it stores itself carry no turn, as on 0.30.0 (A module's inference-request can't carry a turn's privacy, so an async subagent's report on a private turn is published to a channel #789), so a module's own report on work a private turn started wakes, and is read, as any module's does. A result or agent mail sent through Dendrite carries the mark above.'tune-out released (restored)',:9867), opens its turn as on 0.30.0. That turn brings in the channel traffic the hold kept, not an operator's words, and failing closed would make a room's own releases private.630e2a3a07,readerMayCalllets a reader fork callspeak_in_channelonly whereallowChannelSpeechallows, and otherwise only the subconscious's own tools (src/framework.ts:27064–27068there).deriveAgentmakes its agent throughcreateEphemeralAgent(:10849there), which marks the agent's name a run (:10656there), and a run takes no global fallback (:31809there;src/mcpl/channel-registry.ts:5779–5781).api:inference-request, which only an explicit host policy does since the default isalways, wakes the agent when its window flushes with no retake mark, so after a private turn that wake takes the fallback channel where there is one, as on 0.30.0. Two limits are the turn-id mark's, not the retake's. Both come from its code, and no test runs either. At this head, turn ids come from a counter that starts at 0 in each process (src/framework.ts:1545), and the record of private turns starts empty (:4024), so a script wake stored before the restart is read against the new run's ids: it counts as private input only if its id happens to be a private turn of the new run. A private turn's wake from before a restart can then read as public, which matters only where the surface message that opened that turn has also left the history; the previous round's retake held every retake after a restart private, so it didn't meet this. And a second framework made in the same process on the same store starts its record of private turns at the process's turn-id counter (privateTurnsFloor), so it reads every script wake that names a turn from before it was made as private input, a public turn's included, and holds its retakes private while those wakes are in the history. We know of no host that does this. A write deferred and persisted by an earlier release that carries a server's or module's own numericcallerTurnIdreads as private input once, when the upgraded run stores it, since the restore sets any numericcallerTurnIdto0. That fails closed. A search of the repositories we have, connectome-host, context-manager and discord-mcpl among them, finds the key only in this one, wheremainuses it as a tool call's field. A rewind's retry is queued as a request that continues the refused turn (derivedTurnRequest,src/framework.ts:32092for an inference failure and:25976for a refusal), so its start is judged by that turn, not by what the shed removed. Sincemain's fix(refusal): a rewind sheds the newest turn the refused request carried #835, a refusal rewind's repeated refusals shed older messages too, the agent's own turns included. No test runs a shed that reaches into an earlier private turn.inference:startedevent keeps naming that channel, so a tap that tags the activation by it does too.Tests. On Linux, with Node 24.22.1 where a line names no other:
At this head,
191fc24821, withnpm installgiving context-manager 0.24.1, chronicle 0.5.0 and membrane 0.8.0, and mcpl-core pinned at 0.4.0: the typecheck exits 0, and each of the 28 test files that differ from0f11975e48, run on its own withnpm test's runner flags and a 300 s limit per test, passes every test it runs, 1,592 in all,turn-trigger-provenance's 123,explicit-prose-routing's 138 andall-except-e2e's 26 among them.Red first on
main, with the same versions: at0f11975e48with this head'stest/all-except-e2e.test.ts, "under 'delivery', an operator's message while a focus holds is answered in no channel" fails with the answer published inwork, and "under 'latest-inbound', an operator's message after a room was answered is answered in no channel" fails with it published innoisy.For the merge of
0f11975e48: at this head, the typecheck exits 0, and 63 test files, each thatmainchanged since630e2a3a07and each that differs from0f11975e48, run one at a time withnpm test's runner flags, pass 3,640 of 3,640,stop-ends-its-instance's 8 among them. That run's receipt prints Node 24.22.1 and npm 11.19.1, and no platform or package versions. WithsetSleepkeeping no caller turn (src/gate/event-gate.ts:4277),turn-trigger-provenancefails exactly "a sleep's wake, armed by the model in a private turn, speaks in no channel, even where a fallback exists (a focus hold or 'latest-inbound')", and passes its other 122.Each rule added in the merge of
630e2a3a07and the commits after it fails a test when one line of it is removed, with predictions written before each run, each run against the files that hold its requirement's tests. These ran at79571b261f, with that commit's tests. Itssrcdiffers from that of566cbb1a18, the commit this head mergesmaininto, only in the module guard:withoutPrivateWorkMarksin place ofwithoutCallerTurn, and its two callers. The receipts of these runs, and of the runs in the next two bullets, print Node 24.22.1, and no platform or package versions.'delivery'boundary: "under 'delivery', a private turn's boundary that injects an unaddressed follow-up where the turn sent names the room with the injections".tune-out-e2e's "speaks in no channel when a private turn cancelled".dendrite-lifecycle's private restart case.dendrite-lifecycle's private restart case.isPrivateInput's read of the stamp: every private case in D6.79571b261f, since the private turn's words stayed private without it. At04ac1742a3, whosesrcdiffers from566cbb1a18's in the same way and whose test also asserts that the private turn shows typing in no channel, it fails "a private turn that enters a focus keeps no channel, and is told its plain speech stays private".The module guard, red first: with
ccd71b527a'ssrc/framework.ts, which lacks it, under408bb73283's tests, "a module's message that carries a private turn's work mark leaves a channel turn speaking in its channel" fails and its "key" case passes. At408bb73283, whosesrcandtestare566cbb1a18's, it passes. In that run each test had 60 s, with the machine's load average between 37 and 55, and both builds also failed both cases of "a script the model left running in a … turn wakes turns that speak …", which pass at this head with 300 s each (above).The parts that change what happens only where a fallback exists, each removed by one line, at
377053ec90, whosesrcdiffers from566cbb1a18's only in the module guard, with that commit's tests, where every removal also failed the room case of an agent-mail test since rewritten:explicit-prose-routingtests: "a late bounce from a private turn, landing while a channel turn is live, wakes a turn whose plain words stay private…", "a private turn's bounce wakes a turn whose plain words stay private…", and four of the five private envelope cases in B3 that keep their words private with no draft. A run of the whole file at04ac1742a3, with that commit's tests, names those six and the fifth envelope case, "a private explicit turn's envelope that names a channel whose delivery fails keeps its words private, with no draft…", and the file passes 138 of 138 there without the removal;!continuation: "a ' !' continuation of a private turn speaks in no channel…";skip_reply's self-wake: "a skip_reply's wake, armed by the model in a private turn, speaks in no channel…";At
79571b261f: the tune-out coordinator's request hook fails the private cancel cases intune-out-e2eandall-except-e2e, and a script wake's request mark fails "a script's wake queued while its turn was a channel's speaks in no channel once that turn turns private first…".The full check at
0dadcb1c, the head before the merge of62a126c7, afternode_modulesandpackage-lock.jsonwere removed andnpm installinstalled membrane 0.8.0 and context-manager 0.23.1, with mcpl-core pinned at 0.4.0. Its receipt records no chronicle version. Read after the run, its copy held chronicle 0.4.0, the only published version thatpackage.json's^0.4.0admits. Typecheck and build pass. On Node 24.22.1,npm testruns 8,947 tests: 8,885 pass, none fail and 62 are skipped. CI's unforced step passes 138 of 138 tests on Node 24.22.1 and on Node 20.20.2. Each of the 346 test files, run on its own, passes on both. A forced run of the whole suite with the TAP reporter failed no test on either Node, but didn't run every file through: on Node 20 it cancelled two files partway, and on each Node it left out top-level tests of other files. Each of those files passes when run on its own.Red-first: with
1292a955's tests andfab7a0fd'ssrc, each test of R6-1, R6-2 and R6-3 fails: the host's own/undoback to a private question, and back past a private exchange; the API's inference request after a private turn; a nudge after a channel turn that followed a private exchange; anunstickafter a hide in a context that never held private input; and a nudge after a restart following a channel turn. The two retake testsfab7a0fdalready met pass there: a nudge after a restart following a private turn, and a nudge parked while the host removes the private exchange. Each of 43 one-line changes to a piece fails a test of its requirement. Forty-one ran atc46b9e5a20'ssrc, with that commit's tests less its two room-message tests. At0dadcb1c, the head before the merge of62a126c7,srcdiffers from that commit's only in four lines, the notice's wording for a channel no server registered and three tags, and none of the 41 changes touches them. The other two, marked "at0dadcb1c" below, ran at0dadcb1c, in a run that also reran one of the 41 to name the test it fails. The changes: dropping the turn where a script's wake, askip_replywake or asleepis armed, where atune_outcancel is made, or from the request the cancel queues for the resident; letting a start that continues the turn ignore stored private input, which fails "a held turn's retry speaks in no channel when an operator's message stored during the hold makes the turn private" at0dadcb1c; letting a private turn's bounce wake nothing, or go by the live turn; queueing a!continuation, a bounce's wake or an inference-failure retry as a plain request; letting a provider hold dedupe a private wake with a public one; letting the gate's window drop its privacy; leaving the framework's tool-list notice unmarked; letting a module's message keep a private turn's key; letting a conversation fork skip the private refusal; reversingchannel_publish's description; restoring a write's turn id as it was; droppingRETAKEin a merge or on the API's inference request; reading a retake's history as never private or as always private, judging it when the retake is queued rather than at its start, reading only the newest messages, or reading the history as it stood when the agent's last turn opened; sending the re-send's copy without the exhausted turn's id; and, for the merge, leaving unmarked the requests that waited on a budget restart or a held retry, or the ones a quiesce parked beside a continuation, or marking them with the continuations' own turns, drafting a private turn's bounce or the words its resend adds to{{unsent}}, counting a bare resend's words as the private turn's own (at0dadcb1c), letting a drafts round in explicit mode skip the private check (at0dadcb1c), leaving a private turn'schannel_publishrefusal unmarked, dropping the{{unsent}}clause, and letting a script'schannel_publishpast its private refusal; and, for the second merge, delivering a hybrid segment that no send held as a channel turn's, letting an envelope a send held, or one delivered, set a private turn's target, holding a failed envelope's words before the private check, letting a locus round with a send, or a turn's trailing speech, skip it, and letting a send whose place the framework can't read hold a private turn's speech that has no destination. The run at0dadcb1cinstalled membrane 0.8.0, context-manager 0.23.1 and mcpl-core 0.4.0, and its copy held chronicle 0.4.0 when read after it. The other runs' receipts record no package versions.Red-first for the merge at
e85c57db, with predictions written before each run: at the merge,80fb61765b, with the re-send tests added and without the re-send change, "a turn private at a tool boundary by an operator message that wakes nothing whose retries run out is sent again in no channel" and "a room message that comes while a private turn waits to be sent again starts it at once, and gets no reply in the room" fail, each with its reply posted to the room, and the other four re-send tests pass. With the change, all six pass. With the change, but with every held request that feat(turns): send a turn's trigger again when its retries run out on a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits #846 merges into a start left without its turn mark, only the room-message test fails. In that test, the one held request is the re-send's copy. "a private start that takes in a wake for a channel no server registered names it on one line, with its notice openings marked" fails withc46b9e5a20'ssrcand passes at0dadcb1c. These runs' receipts record no package versions. Read after the runs, the copies they ran in held chronicle 0.4.0, membrane 0.8.0, context-manager 0.23.1 and mcpl-core 0.4.0.For the merge at
62a126c7, with predictions written before each run, and chronicle 0.4.0, membrane 0.8.0, context-manager 0.23.1 and mcpl-core 0.4.0 installed: atb2f6b4adbf, the head before the merge of630e2a3a07, the typecheck exits 0 andtest/prose-drafts.test.tspasses 278 of 278. With&& !routing.nonChannelOrigindropped from the condition for feat(prose)!: a heartbeat turn holds its plain speech as drafts #861's start notice (src/framework.ts:24156), the private-wake and stored-message tests fail in locus and hybrid mode, four tests. With!toldPrivatein its place, as in an earlier build of this merge, the two private-wake tests fail. At a commit withb2f6b4adbf'ssrcand three fewerprose-draftstests,test/turn-trigger-provenance.test.tspasses 112 of 112.The whole suite at this head runs in this pull request's CI. No other run of it was made at this head.
Not verified.
Agent.runInference, or toAgent.runInferenceWithInjections, which it calls, runs an agent on that fallback. Apart fromrunInference's own call, nothing in this framework'ssrccalls either, and neither does connectome-host'smainat92bf4b182a.'delivery'. Those tests run onturn-trigger-provenance's stand-in registry, where the turn that answers has a fallback, anddendrite-lifecycle's restart test asserts that the start its interrupted notice wakes is private, not where its words go.555b9efdsends a resident's words to a subagent withsendAgentMessage(src/modules/subagent-module.ts:2908), sends a subagent's outcome back withdeliverAgentResultorsendAgentMessage(:2532–2539), and starts a spawn withcreateEphemeralAgentandspawnedBy(:3205–3210).Out of scope. Three ways a turn can lose its continuation are
main's own, and this branch leaves them as they are:resolveLocus's global fallback can still pick a channel a conversation fork owns, as it can for any ambient wake.Written by Tabitha-2696, with design calls by Ada-1017. Kestrel-2409 read the rework. In the round before the retake's, Ezra-2423 read the source and the merge of
main, Abigail-2676 the tests, and Alfred-2570 and Desmond-2579 the stored data. In the retake's round, Cordelia-2434, who built none of it, weighed the retake alone against the smallest rule that meets its requirement and read the source. Abigail-2676 read the tests. For the merge ofmainat4976c67d, Ezra-2423 read the merge and the commits on it, and Cordelia-2434, before it was built, set out the least it needed: a mark on the requests wheremainsplits the batch. For the merge at2021d49d, Ezra-2423 read the merge and the commits on it, and Alfred-2570, who built neither #824 nor this change, weighed where the two meet, and his probe found the case of a send whose place the framework can't read. Ezra-2423 read the merges at36f27982and8ff287ddtoo. For the merge ate85c57db, Ezra-2423 read the merge and the two commits on it, and ran nothing. Alfred-2570, who built neither #846 nor this change, weighed the re-send fix, in an earlier form, against the least it needed, which he wrote before he opened it, and found no test of a room message that comes during the wait. That test was then added. For the merge at62a126c7, Ezra-2423 read two earlier builds of it, and ran nothing. His reading of the second raised a heartbeat start made private by stored input, which the stored-message tests now run. For the merge at630e2a3a07, Ezra-2423 read the merge hunk by hunk, by reading only, and found nothing to fix, and the two places he found where it meets Dendrite are what9778fcdd94andf8d1ea7f87change. Laurel-2581 weighed which parts change anything only where a fallback exists, and her read of the commits after the merge found that a module could write the work mark, which a module's write paths now drop. For the merge at0f11975e48, Ezra-2423 read the merge withgit show --remerge-diff, againstmain's changes since630e2a3a07, by reading only, and found nothing to fix.What each part is for, and what it costs (Tabitha-2696)
This section is ours. It weighs each part of the pull request as it stands, the author's included: what each buys, what it costs to keep, and the simpler options, with what was cut.
What it's for. A resident's plain reply to a private surface must not be published to a channel the resident didn't choose. In the incident, a fresh WebUI turn took
resolveLocus's fallback, the most recent inbound channel. On 0.30.0 that fallback is an open focus's channel, or under'latest-inbound'the most recent inbound channel, and a channel turn that takes in private input keeps its channel.The least that meets it, and what each part costs:
isNonChannelSurfaceEvent), about ten lines. A surface event is a channel's when itsmetadata.channelIdnames one, and private otherwise. A whitespace-only id names none. So a surface named tomorrow fails closed, where a list of private sources would fail open the day a new surface ships. The cost is that a bridge that forgetschannelIdgoes mute, and its receipt says why. The framework's own tool-list notice, the oneexternal-messageit builds itself, is kept out by a set only the framework writes, about five lines (R5-2): without it, a host that stores everyexternal-messagewould make a resident's next turn private whenever an MCPL server's tools changed.surfaceSourceFor's stamp now reads the same rule, one line, so the classifier and the stamp can't disagree.InferenceRequest.nonChannelOrigin), carried by the direct path and the gate's wake: a field on two types and their readers. In the gate, a debounce window that holds a private message wakes one private turn and names no route. A window that holds only private messages is timed from the newest of them. That's about ten lines inwakeProvenance, and without them a window's channel message would route the private turn.TurnRouting.nonChannelOrigin), frozen as fix(routing): each turn keeps its own routing state, so a tool-ended turn's queued prose follows that turn #312 freezes the locus, so every start of the turn keeps it, with no locus while it's set. One field.some()over the batch. It replaces the split, about 170 lines of holds and requeues, which still let the room's turn run with the operator's words in its request.notePrivateInput): a set, one call where a stored message's notes are taken, and one where a start's flush takes them. A tool boundary that ends its turn, or a silent turn's, sets it too, for the private input it stores, since neither turn answers it. A start reads the mark, and it's spent once a start that can publish has begun its stream. A silent start that reads it is private as well, so itschannel_publishnames its channel. Without it, a start woken by a room message, whose context holds an operator's message stored before it, would answer in the room. That happens when the operator's message was stored while the agent was idle and its wake waits in a gate window.makeTurnPrivate, about twenty lines, and its notice, about twenty more (storePrivateTurnNotice). It runs at a start that continues the turn and at a tool boundary that injects private input (R1-1a, a few lines that read the existing stamp). It removes the locus, the trigger channel (which the registry reads as a turn's channel when it has no locus, so a private opening start sets none either) and the sticky destination, since the agent's next words may answer the operator, and, unless the turn is a silent heartbeat tick, it tells the agent once, whatever destination the turn had. An explicit-mode turn with none would otherwise meet the private input with nothing saying its words now stay off channels. A start that the mark alone makes private, though a channel's wake opened it, is told why too, in every mode, so its resident doesn't take its reply to be the room's.pinTurn): a three-line helper at the three places a pin is set. Without it, a private turn's own>>>, or a round queued before the private input and delivered after it, would set a destination the turn's later speech follows: a resident who believed the notice would answer the operator in that channel. An envelope runs to the next envelope line, so a message of any length still goes out whole.derivedTurnRequest, and the budget restart's and the re-inference's turns): one helper and two spreads, so what a private turn causes stays private. The auto-continuationmainadded takes the helper too: without it, a private turn's auto-continuation opened a public turn, which took the global fallback. At a bounce, a!continuation and the rewind and failure retries, it changes what happens only where a fallback exists, since 0.30.0's changelog gives a turn with no locus none under'delivery'while no focus holds. Its tests there run on the stand-in registries, and removing it at each fails them (Tests).retakeRequest, itsRETAKEmark, whichcarryWakeskeeps through a merge, on an operator'snudgeandunstickand on the API'sinference.request; andretakeIsPrivate): about seventeen lines of code, and nothing stored. A retake adds no input. It compiles the agent's context as it stands and answers it, so whether its answer can carry private input depends only on that context. So its start, inbeginAgentTurnbefore the start records its checkpoint, is private exactly when the agent's stored history on its branch holds private input by the marks the store already writes (isPrivateInput). That's onesome()overgetAllMessages(), the cached view the turn's compile reads next. Without it,/nudgeafter a private exchange posted the retaken answer to the fallback channel (R5-1). The previous round instead kept a copy of one fact about the context, which turn it ended on, set when a turn opened and cleared at each move the framework itself makes. It went wrong wherever anything else moved the context: connectome-host's own history commands (R6-1), a module'sremoveMessages, a host'scm.removeMessage(s). Where the copy was missing it failed closed, which held a channel-only host'sunstickornudgesilent after a restart, a hide or a rewind's shed (R6-3). Keying the copy by branch, as R6-1 suggests, would follow branch moves but not removals on the same branch, so its clears and its unknown case would stay. We ran it in place of ours at an earlier version of this branch, with a test written for the run: after a private exchange and a channel turn, a host's removal of the channel turn on the same branch, then a nudge, posted the retaken answer to the fallback channel. With ours at that version, the same test passed, and the nudge's answer went to no channel. No test at this head runs that case. Reading the context removes the copy, its clears and its unknown case together. Its cost is that on a host with a private surface beside channels, a retake is held private while private input stays in the history, even right after a channel turn. A rule that reads only the context's newest turn would answer that channel turn in its channel, but it would bring back the question of which exchange a silent tick, several undos, or an undo then a tick leave the retake answering, which is where the earlier holes came from. We timed the scan at0dadcb1c, on Node 24.22.1 on Linux with chronicle 0.4.0, membrane 0.8.0, context-manager 0.23.1 and mcpl-core 0.4.0 installed, with a channel-only history, where nothing matches and every message is read. Each figure is the first read after a new message is stored, then a read with nothing new: at 1,000 messages, a median of 0.24 ms (3.2 ms at most), then 0.04 ms; at 10,000, 3.4 ms (15.4 ms at most), then 0.48 ms; at 50,000, 3.8 ms (16.7 ms at most), then 0.88 ms. A history that holds private input stops at the first match: with the private message first of 50,000, the read with nothing new printed a median of 0.00 ms. The one-minute load average was 10.3 when the run started and 19.1 when it ended, 729 s later. Like the derived requests at a bounce, it changes what a retake does only where a fallback exists.notePrivateTurn,isPrivateTurn): a set of the 4,096 highest private ids and a floor, written at the two places a turn becomes private, about twenty lines of code. A script's report, deferred while the host is quiesced for an update and stored by the next run, carries its turn id as0, set by one spread in the restore, so it can't name a turn the next run gives out. It lets work that comes back after its turn has ended go by that turn as it stands then. A flag taken when the work started would read "channel" for a turn that took in private input afterwards, and the turn the work wakes reads that input. Failing closed past the cap, or for an earlier run's report, costs a rare late wake its channel.CALLER_TURNSwithcontinuesTurns,callerTurnsOfandcarryWakes;workTurnOf,requestForTurn,WakeProvenance.callerTurnIds, and the turn idarmSelfWake,setSleepand a tune-out cancel take): about forty lines across the framework and the gate, with one judgment inturnIsPrivate. Without them a private turn's script, self-wake, sleep or tune-out cancel woke a public turn, which took the global fallback. A request judged when it was queued would miss a turn that turned private afterwards. The end of a focus that a private turn asked for wakes the resident by that turn too, through one more parameter ofcancelAllExcept. These requests also change what happens only where a fallback exists.AgentRecord.privateTurnandHeldMail.privateTurn, the mark wherecreateEphemeralAgentmakes an agent and where mail is handed to a run,sentFromPrivateWork, and the stamp on a result, a message and a restart's interrupted notice).8c5e233696, which adds them with the module guard below, adds 63 lines tosrcand removes 7, 25 of the added lines insrc/dendrite/. On 0.30.0 what such work sends back is ordinary input, so the turn that reads it answers where it would answer anything else. Each part's one-line removal fails a test of it (Tests). Those tests run on the stand-in registry (Not verified).withoutPrivateWorkMarks, which replaceswithoutCallerTurn): nine more lines in the function that already dropped a module'scallerTurnId. Without them, a module's stored message could carry the mark and make a channel turn that reads it private, which "a module's message that carries a private turn's work mark leaves a channel turn speaking in its channel" fails on.!open.nonChannelOrigin,:36960, and a branch of the hold's answer). Without the condition, the private turn takes the focus's channel as its locus. Its words still stay private, but the typing indicator, which follows the locus (followTurnLocus,:25010–25016), shows in that channel. The answer's sentences each gain the private exception, and so do thefocusandtune_outdescriptions, which changes every resident's tool list once.:21745). Without it, the room's next turn after a restart isn't told its channel, since 0.30.0's stored record still names the channel the agent was told of before its turn turned private.isPrivateInput,CALLER_TURN_KEY,withoutPrivateWorkMarks): about fifteen lines. Without it, a public turn running when a private turn's script wake was stored read it at its next boundary, stayed public, and could publish what it said. Only the framework writes the key: a server's copy is dropped (HOST_METADATA_KEYS), and so is a module's where its writes enter, so neither can mark its messages as a private turn's work and keep the resident's next turn from replying in its channel.noticeChannelMessages), about seventy lines of code with its sites: the channels a start gathers from its batch (wakeChannelsOf) and a gate window lists, kept when wakes merge in a quiesce or a provider hold (carryWakes) and given to each agent by its route's rule, a live boundary's would-be re-pins, and a private turn's arrivals, which it now notes and names instead of re-pinning; and 0.30.0's record of the channels named in the turn (addressedElsewhere), which replaced this branch's own, so each channel is named once. Under'delivery', five lines at a tool boundary (:16705–16709) name an unaddressed follow-up where the private turn sent, which 0.30.0's notice, for messages that addressed the agent, doesn't. Without it, a room's message absorbed into a private turn was dropped silently: the resident answered both in plain speech, and only the room's half was kept. The wakes carry the channels. A per-agent set of the channels whose messages were stored since the last start could have carried them instead. Such a set would be smaller, but it would also hold messages whose wakes the gate dropped or still holds in a debounce window, and naming those would steer a send at a message its own turn later answers too. The wakes name exactly what the private start took. That comparison comes from reading the code, and no run compared the two.suppressPrivateTurnProseat the three sites where speech would otherwise reach routing with no locus, and hybrid'sallowLocus): one counter, one note. In explicit mode,keepPrivateTurnProseis one condition and one notice: a bounce there would hold a draft, and resending it is the step into a channel that the turn's privacy is there to prevent. A private hybrid turn's unprefixed speech is counted as kept, not bounced as having no destination. Words a send holds back are held as drafts, as in any turn, since resending them takes a destination the resident names.'latest-inbound', where 0.30.0 still re-pins.channel_publishwithoutchannelId, refused in a private turn: about forty lines with its round's count and the XML fallback's scan, since its default is a channel the resident can't see. It's judged by the turn the call was made in, and the round by its stream, since a later turn can be live by the time a call dispatches. A script's is refused when its start turn is private, one condition with the record, since a script runs apart from its turn.channel_publish's description, one clause in it and one inchannelId's: without them the description offers a private turn a default it then refuses, which an agent reads as true. The cost is one rewrite of every agent's tool list, as 0.27.0's change tochannel_close's description made.channel_openand.sleepin a private turn: a branch each, so opening a channel to read it doesn't route the turn's next words there, and a sleep announcement doesn't take a channel nobody named.SpeechContext.nonChannelOriginand the Discord module's return: a field and a line. The module is the one built-in output path besides the registry.surfaceRig,SurfaceModule,SlowModule,robot,storeOperatorMessage): the real scheduler and stream, with a stub registry that records each post and can hold it (holdRoutes,whileRunning).quietSourcesstores a surface's message without a wake, andunstoredSourceswakes for one that nothing stores, so a gate window's private wake can be its only mark.codeExecutionturns scripts on, andSlowModule's tool answers when the test releases it, so a script can be left running.storeOperatorMessagestores a message with the stamp the framework writes, where the other tests send the event the framework stamps.explicit-prose-routing's trigger names the channel its stub registry resolves every locus to, so its sticky-target tests run in a channel's turn as they did onmain, and its private-turn tests ask for a private trigger.tune-out-e2e's cancel test runs through the MCPL fixture server, with a surface module of its own.held-turn-retry's input module stores a message fromQUIET_SOURCEwithout a wake, so an operator's message can arrive during a hold. D5's next run is a second framework on the same store, in the same process, and the restore runs when it's made.hostUndomoves the context as connectome-host's/undodoes, through the context manager alone, andApiModulesends the API's inference request. Each test pins one rule above, named in its title. The helpers that read back what a test checks:channelNotices,named(the channels each notice names),noticeBeforeReplyandstored(the store as text, for a refusal) in the provenance file, withPRIVATE_FROM_HEREandNO_DEFAULT_CHANNEL, the notice and refusal they look for; andpublishinganddrivenincode-execution's, whose harness runs the framework in steps.Cut when this pull request was reworked:
inference:started's mark with the module's typing, which is presence, not a reply;Lottie-2661 wrote this description, from Tabitha-2696's facts.