Repository navigation
fix(routing): a turn from a non-channel surface pins no inferred locus - #190
rufalupa666-netizen wants to merge 170 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 |
Catch-up 12. main moved from 4429afe (catch-up 11) to a25e38b: anima-research#969's locked prose routing. Where the two meet, the rule is: a private turn isn't a locked turn while it's private. Its plain speech goes to no channel, it shows no typing, and it's told it's private, as in every mode; the agent's lock stays stored, untouched, and governs every turn that isn't private. Eleven conflict blocks, resolved under that rule: - src/framework.ts, TurnRouting: anima-research#969's locked, endRouted and heldForMove fields and its locus doc, without the "Frozen when the turn opens" sentence this branch removes. - TurnRouting.addressedElsewhere: main's comment as anima-research#969 has it, with this branch's use of the set for a private turn's held channels. - The [delivered] receipt: proseRoutingOf(agent) === 'disabled' or a private turn's (both sides' conditions). - announceLocusIfChanged's null-locus notice: anima-research#969's text for a locked turn, this branch's otherwise. - The opening start: anima-research#969's locked branch takes only a turn that isn't private; a private turn takes this branch's, with no locus, no announce, and its private notice. anima-research#969's silent branch is kept as it is: a silent start runs as disabled mode and isn't the open turn, and no reader of a silent start's routing gives a private one anything different for its being locked. - The continuing start: this branch's private/non-private split; a private locked turn names the channels it holds messages from, and a non-private locked turn gets anima-research#969's delivery notice. - The typing indicator's comment: anima-research#969's, without "Once a locus or hybrid turn has a locus it always has one", which makeTurnPrivate makes false. - tune_out: both locus conditions; its result keeps this branch's home and private texts, gives a locked agent's private turn its own (this turn private, a turn that holds none at the lock), then anima-research#969's locked text. - sleep: both else-ifs. - src/types/framework.ts, speech.locus: both sentences, the locked one excepting a private turn.
The merge resolves the blocks where anima-research#969's locked routing meets this branch's private turn. These are the rule's sites outside them: - makeTurnPrivate clears routing.locked with the locus, so a locked turn made private mid-way takes the private path everywhere a locked turn's would read its (now empty) lock as "drafts". - A private locked turn names its wake's channels, as a private locus or hybrid turn does: locked takes the locus path. - prose_locus in a private turn: a query says the turn's plain speech stays private and where it goes on a turn that holds none; a move records the lock for those turns and leaves the private turn's locus, channels and typing as they are, and says it applies from the next turn that holds no private input. Its refusals say the same of where speech goes now. - channel_open in a private locked turn takes this branch's private answer: it moves nothing, and says the turn's plain speech stays private. - A closed channel's invitation made while a private turn is open gives the deferring form ("the latest word on where it lands"), naming no lock, since the invitation stays in the window. - The locked primer says "on every turn that holds no private input". - A private turn names no channel to anyone. The channel registry gains a privateTurn option, which the framework answers from the turn's nonChannelOrigin; incomingFor and buildChannelContext read it before anything else, so an MCPL server's context/beforeInference channels name no default outgoing channel, a conversation fork's home included, and no incoming message for a private turn, in every mode. Before, a private turn's context took the global fallback (a fork's, its home) as its outgoing channel and the latest inbound message as the one it answers; this changes the branch's behaviour in locus and hybrid mode as well as locked. The home's other readers are unchanged: the refusal reaction and failure log already skip a private turn, and resolveLocus is asked for a private turn only after privatePublishRefusal has refused a publish with no channel. ChannelRegistry is exported, so privateTurn gets an .added fragment, and activeChannelResolver's doc, which said a fork's home always wins, now names the private turn's context as the exception. Docs: docs/locked-prose-routing.md names the private exception; the 'locked' doc in AgentConfig says the choice applies again after a private turn; the non-channel-origin-locus fragments name locked mode beside locus and hybrid where a private turn's notices and answers apply to it. Tests: locked-prose-routing gains five cases (a private turn speaks nowhere, shows no typing, gets no lock notice and its no-channel publish is refused; prose_locus's query and move in a private turn, with the next turn at the moved lock; a locked turn made private mid-way, whose query and move answer as a private turn's; channel_open in a private turn; the invitation during a private turn), and its primer case reads the new primer. turn-trigger-provenance gains a locked agent's private start naming a room's message it took in, with no typing, and a locked agent's over-budget private boundary naming an addressed message for its restart. all-except-e2e gains a locked agent's private turn entering a focus, and, in locus, hybrid and locked mode under both speech.locus rules and for a conversation fork's private turn, a private turn's server context naming no channel.
Catch-up 13. main moved from a25e38b (catch-up 12) to 074ac60: anima-research#959 (no tune-out hold without a reader) and anima-research#183 (an MCPL server's tools kept across a failed re-list). Two conflict blocks, both in src/framework.ts: - create()'s tune-out coordinator: anima-research#959 moved the inline new TuneOutCoordinator(...) into makeTuneOutCoordinator(options), with framework. written as this., holdWithoutReader dropped and the announce option added, and create() calls it where a reader is configured, else releaseTuneOutsWithoutReader(). This branch had changed two lines of the inline block, its requestInference hook, to pass the caller's turn id to requestForTurn. main's side is taken in create(), and those two lines go into makeTuneOutCoordinator as this branch wrote them, with main's this. for framework.: requestInference: (agentName, reason, source, callerTurnId) => { this.requestForTurn(agentName, reason, source, callerTurnId); - New private methods at the same place: this branch's privatePublishRefusal, and anima-research#959's tuneOutOnRecord, makeTuneOutCoordinator and releaseTuneOutsWithoutReader. Both kept, unchanged. src/tune-out/coordinator.ts, src/types/framework.ts, docs/dendrite.md and test/publish-default-locus.test.ts merged without conflict. Where the two meet: anima-research#959's two new release paths, at a start with no reader and when a persistent reader stops, call requestInference without a caller's turn id, so requestForTurn gets none, as for any wake that isn't a caller's. This branch's private answers for tune_out and focus apply where those tools are offered, which is now only with a reader.
…search#959 requires anima-research#959 removed tuneOut.enabled: create() refuses it, and tune_out and focus are offered only with a reader. This branch's tests that hold a focus configured tuneOut: { enabled: true }, so after the merge of main 074ac60 each failed at create(). They configure the reader main's own tests in these files use, subconscious: SUB, instead: test/all-except-e2e.test.ts's private-turn-and-focus cases (an operator's message under either locus rule, a private turn's, a conversation fork's and a locked agent's focus answers, and the focus-cancel wake under latest-inbound), and test/publish-default-locus.test.ts's host and module calls carrying an open private turn's id. A focus cancel wakes the reader as well as the resident. The focus-cancel wake test hands its scripted responses to whichever stream starts next, one turn's worth at a time, so the reader's turn could take the resident's and the test waited out its time limit. Its stream override now answers the reader's turn on its own, by the reader's system prompt, and hands the script only to the resident's. No assertion changes.
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R9 at 05786ea · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R9-n are stable; cite them in fix commits.
Reviewed at 05786ea against main 074ac60 (the head's last merge is main at that commit, so the merge-base is main's head)
Nightly dual review · round R9 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R9-n are stable; cite them in fix commits.
Verdict: changes requested. One major, four minors, no blockers.
R8-2 is fixed. R8-1 is fixed for the restart case R8 named, and the mutants show the new tests pin both fixes. One kind of stored row still defeats the new opening scan (R9-2). The major is new to our reviews: a turn made private part-way through keeps naming its old channel in its typing indicator and in every token trace. connectome-host's TTS relay reads those traces and voices the private answer for that channel (R9-1). That is the incident this PR exists to close, through a path its description calls "presence, not a reply". The fix is a few lines, and the code already does the same thing for a locked prose_locus move.
Disposition of round-8 findings
| R8 | At 05786ea | Evidence |
|---|---|---|
| R8-1: the note that private input waits is lost at a restart | Partial. The case R8 named is fixed. One residue remains: R9-2. | createAgent rebuilds the note with holdsUnansweredPrivateInput (src/framework.ts:1700-1712, called at :16353). The scan uses the resident's own view predicate (:16318-16321). The three restart tests (test/turn-trigger-provenance.test.ts:2063) fail when the call is removed (3 of 3). Our probe of a message that lands while a channel turn is streaming also holds: the message is stored after the stream's own row, and after a restart the next room turn routes [], then room, as in one run. A /puppet pair stored after the unread message is read as the resident's answer (R9-2). |
| R8-2: a retake reads run-local turn ids | Fixed. | retakeIsPrivate reads only isMarkedPrivateInput, which is the surface stamp and dendrite.privateTurn (:1695-1698, :16183-16185). Both tests, "…a second framework opens in the same process…" (:2281) and R7-3's deferred-report test (:1993), fail when this.isPrivateInput is put back (2 of 2). The description now names connectome-host's switchSession in place of "no host". The changed fragment cites #789 where it describes what a host's own write carries (changed.md:87-89), as we suggested. |
The desk's facts hold: this is round 9, and R8 at 32bb4a8 requested changes. On GitHub that review's state is "commented"; the verdict is in its body.
Major
R9-1. A turn made private part-way through keeps its old channel in typing and token traces, and connectome-host's TTS relay voices the private answer there
What the PR says:
ChannelRegistry's newprivateTurnoption says a private turn "names no channel to anyone" (src/mcpl/channel-registry.ts:1114-1116).inference:tokens.channelIdis documented as "the turn's locus" in locus and hybrid mode (src/types/trace.ts:173-185). A private turn has no locus.- The description's "What this leaves" covers only the framework's typing indicator and
inference:started. Its "Cut" list calls that mark "presence, not a reply". Neither changelog fragment mentions typing or traces.
Mechanism:
makeTurnPrivatedeletes the turn's locus, pin, trigger channel and lock. It leavesrouting.typingand the indicator alone (src/framework.ts:22611-22627).- Each round,
followTurnLocusreturns early when the locus is empty (:25982). Only its locked branch setstypingChannel = null(:25977-25980).- So
typingChannelkeeps the old channel. tracedChannelId()(:25986-25987) stamps that channel on everyinference:tokensandinference:content_blockof the private rounds (:26147-26157,:26195-26201).- Explicit mode keeps its start's typing channel in the same way.
- So
- connectome-host
main(68548adf) loadsTtsRelayModulewhenmodules.ttsRelayis set (src/index.ts:461).- The module adopts the first channel it sees on
inference:startedorinference:tokens(src/modules/tts-relay-module.ts:248-249,:291-298). - It sends every later text chunk to the relay for that channel, as visible text (
:298-309), untilinference:completedorinference:turn_ended(:335). - A tool boundary emits neither, so the private rounds are voiced.
- The module adopts the first channel it sees on
Probe (the head's built dist/, turn-trigger-provenance's stand-in registry; sketch below):
- A room's addressed wake starts a turn that calls a tool.
- A TUI message arrives during the tool, and the boundary injects it, so the turn is private from there.
- The next round says "PRIVATE ANSWER: the door code is 4711".
Results:
- Locus and hybrid: nothing is routed, so the text fix holds. Typing shows in
discord:g:roomfor both rounds. The private round'sinference:tokenscarrieschannelId: 'discord:g:room'. A copy of the relay's channel logic voices the private answer fordiscord:g:room. - Control, without the TUI message: the same text is routed to the room.
- A turn private from its start traces no channel, as the PR intends.
Why major: this is no regression, since on main the same reply posts to the room as text. But it is the incident this PR exists to close, on the host the description names, through a path the description dismisses as presence. On a host with the relay, an operator's WebUI question answered part-way through a room's turn is read aloud for the room. It also contradicts the registry doc above.
Fix:
- Treat a private turn the way a locked move to drafts is already treated:
- In
makeTurnPrivate, stop the indicator and deleterouting.typing, asprose_locusdoes (:22091-22096). - In
followTurnLocus, and for explicit mode's typing channel, settypingChannel = nulloncestreamRouting.nonChannelOriginis set. - The private rounds' traces then name no channel.
- In
- Correct the
inference:tokensdoc and the description's "What this leaves". - Name trace taps in the breaking fragment's "Who needs to act": a tap that voices or relays chunks must follow each chunk's
channelId, and treat a chunk with none as going to no channel. connectome-host's relay adopts its channel once per activation, so it needs that change too. That change belongs to the host, not to this PR. - Add the probe as a test: the private round's
inference:tokenscarry nochannelId, and typing stops when the boundary makes the turn private.
Minor
R9-2. A /puppet pair stored after an unread operator message reads as the resident's answer, so after a restart the next channel turn publishes (R8-1, the residue)
Mechanism:
- The opening scan stops at the first row it can see whose participant is the resident, unless that row is a silent heartbeat tick's (
src/framework.ts:1708). puppetToolCallruns only while the agent is idle with no turn alive, and requests no inference (doc:28084-28105).- It stores a bare
tool_useunder the agent's name, followed by itstool_result(:28224-28234). - By design, the stored rows carry no metadata.
- It stores a bare
- In a running framework the note survives a puppet, so the next start is private. After a restart, the scan meets the puppet's row first and returns false.
- connectome-host offers this as the admin command
/puppet(src/commands.ts:307,:1001at 68548adf). - Of the rows the framework itself writes outside a turn, we found only the puppet's under the agent's own name. Stored injections use
system_context:<namespace>(src/stored-injections.ts:58), and notices areuserrows. A module that stores a row under the resident's name would have the same effect.
Probe (two frameworks on one store, as the R8-1 tests run):
- A room turn runs.
- A quiet console message is stored.
puppetToolCall('scout', 'robot--move', {})runs.
Results:
- In one run: the next room turn routes
[]. - After a restart: the note is empty at boot. The next room turn routes
['discord:g:room'], and its request holds the operator's message. - Control, the same restart without the puppet:
[].
Why minor: three things must line up. An operator's message must wait unread, as during a provider hold. An admin must run /puppet while it waits. And the host must restart before any start reads the message.
Fix:
- Persist the note per agent, R8-1's first suggestion: write it where the note is added, and clear it where a start takes it.
- Or have
puppetToolCallrecord, durably, that the agent had unread private input when it stored the pair, and have the opening scan read that record. - Add the probe as a test.
R9-3. On a host with no private surface, the first start after a restart that reads an unanswered script report replies in no channel, and the breaking fragment doesn't say so
Mechanism:
- The opening scan counts any numeric
callerTurnIdas private (src/framework.ts:1707).scriptWakestores the start turn's id on every report (:29005-29010).- The restore sets a deferred report's id to
0, which counts as private at its landing (:10117-10123, floor:4285).
- The PR's own tests pin this on a rig with no private surface:
- "a channel turn's script report stored before a restart, which no start read, keeps only the next run's first start out of every channel…" (
test/turn-trigger-provenance.test.ts:2209-2233); - R7-3's deferred-report test (
:1993).
- "a channel turn's script report stored before a restart, which no start read, keeps only the next run's first start out of every channel…" (
- The deferred case is the ordinary update path. A script still running when an update quiesces the host reports its end as a deferred write, so each such update costs the room its reply to the first message after the restart.
Where the text falls short:
- The changed fragment states the rule (
changelog.d/non-channel-origin-locus.changed.md:182-187). - The breaking fragment is addressed to "Hosts with a private surface beside channels" (
breaking.md:1-3). - Its "Unchanged" bullet says a start that holds no private input replies in its channel as before (
:66-73). A host with no private surface has no reason to read the first, and the second tells it nothing changes.
We accepted this behaviour in R8 as documented: R8-2's probe records the first room turn routing []. What we missed is that it reaches hosts with no private surface.
Fix:
- Name it in the breaking fragment, under a heading for hosts with no private surface that run scripts.
- Better, store the verdict where it is known:
- stamp a framework-owned boolean when a report is stored while its start turn is private (as
dendrite.privateTurnis stamped), and when a deferred write is persisted; - have the scan and the restore read that boolean rather than any numeric id;
- fail closed only for rows written before this release.
- stamp a framework-owned boolean when a report is stored while its start turn is private (as
R9-4. A retake reads the raw store with every blob resolved, not the resident's view
Mechanism:
retakeIsPrivateisgetAllMessages().some(…)(src/framework.ts:16183-16185).- In context-manager 0.24.2:
- direct accessors ignore
viewFilter(dist/src/context-manager.d.ts:44-48); getAllMessagesresolves blobs by default (dist/src/blob-manager.d.ts:5-7).
- direct accessors ignore
Consequences:
- Rows the view excludes count. One example is a row in the shared slot marked
dendrite.privateTurnthat is another resident's stored context injection.- The PR's view tests pin that such a row leaves an ordinary start public, and a restart's first start too (
test/turn-trigger-provenance.test.ts:2115-2166). No test covers a retake. - The description says the opposite:
getAllMessages()is "the cached view the turn's compile reads next", so input that "was hidden … doesn't" count ("Current architecture", the retake bullet).
- The PR's view tests pin that such a row leaves an ordinary start public, and a restart's first start too (
- The cost. Every nudge, unstick and API
inference.requestbuilds the whole slot, with every image and document decoded, beforesome()can stop.
Both fail closed or cost time, so this is minor.
Fix:
- Reuse the opening scan's loop:
getMessageWindow(…, { resolveBlobs: false }), filtered by the resident'ssees(storeSees), stopping at the first marked row. - Add a retake case to the hidden-row tests, and correct the description.
R9-5. Two stale lines in the description (description only)
- "Cut when this pull request was reworked: the hooks' channel context…" ad22ac4 puts this back:
ChannelRegistry'sprivateTurn(src/mcpl/channel-registry.ts:2545,:2588) and the.addedfragment. - "A private turn's
beforeInferencehooks are told the channel context 0.30.0 gives a turn with no channel." For a conversation fork, 0.30.0 gives the home as the default outgoing channel, and this head gives none (:2588).
Drop the first line and reword the second.
Verified fine
- Build, CI and the merge.
- The head merges
mainat 074ac60 with nothing left over. - 05786ea changes tests only: it gives each tune-out hold a reader, as #959 requires.
- The
pull_requestCI on this head is green. - Locally, the build passes, and the 28 test files this PR touches pass 1,600 of 1,600 (Node 22). We didn't run the full suite locally.
- The head merges
- The R8 fixes' tests pin them. Removing the opening scan fails all three restart tests. Putting
isPrivateInputback in the retake fails both R8-2 tests. - Rows that could pass for an answer. Framework notices and
[delivered]receipts areuserrows. Stored injections aresystem_context:<namespace>rows. A message that arrives while a stream runs is stored after the stream's row (probe above). Silent ticks are skipped. - Locked routing (ad22ac4).
makeTurnPrivateclears the turn'slocked, not the stored lock. A private start loads no lock (:25091-25105). Only an explicitprose_locuswrites the stored lock (:21995-22066).incomingForandbuildChannelContextname no channel for a private turn (src/mcpl/channel-registry.ts:2545,:2588).- The refusal reaction and failure log read the turn's privacy first (
:13746,:32858).
- 05a88cc.
workTurnOfis read only forchannel_publishat channel dispatch (:37689). - Private speech guards. Routed prose is suppressed for a private turn. The built-in Discord module's
onAgentSpeechreturns onnonChannelOrigin(src/modules/discord/index.ts:926). - connectome-host
main(68548adf).- Its
external-messageproducers are still the four operator surfaces: headless, CLI, WebUI and TUI. - None of its
ctx.callToolcalls carries acallerTurnId, so the new host-call refusal reaches none of them. - It depends on
@animalabs/agent-framework ^0.32.0.
- Its
- Bounds.
- The private-turn record is capped at 4,096 ids.
privateInputStoredholds at most one entry per agent. - The opening scan stops at the resident's last row of its own. It reads the whole slot only for a resident that has never spoken, once per framework, 64 rows at a time without blobs.
- The private-turn record is capped at 4,096 ids.
- Description citations. Every one we checked holds at this head:
:1674,:1700,:4284-4285,:10139,:16353,:17139,:22494,:24382,:24983,:25972-25985,:26944,:31055,:33203,:38457.
Probe sketches (R9-1, R9-2)
Both use the head's built dist/, MockMembrane, a surface module whose console source stores without a wake, and turn-trigger-provenance's stand-in registry: resolveLocus gives the trigger channel or discord:g:lounge, and startTyping is recorded.
// R9-1: one turn, made private at its tool boundary
membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'm1', name: 'robot--move', input: {} }], 'tool_use'));
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'PRIVATE ANSWER: the door code is 4711' }]));
// robot--move's handler: framework.pushEvent({ type: 'external-message', source: 'tui', content: 'what is the door code?', metadata: {}, triggerInference: true })
framework.onTrace((e) => { /* record inference:started / inference:tokens channelId; relay logic: adopt the first channelId, voice each text chunk until inference:completed */ });
pendingRequests.push({ agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:g:room', addressed: true, timestamp: Date.now() });
// locus, hybrid: routed [] ; typing ['discord:g:room', 'discord:g:room']
// inference:tokens { channelId: 'discord:g:room', content: 'PRIVATE ANSWER: …' } ; relay voices it for discord:g:room
// control (no TUI message): routed [{ text: 'PRIVATE ANSWER: …', to: 'discord:g:room' }]// R9-2: two frameworks, one store
await channelTurn(); // -> ['discord:g:room']
fw.pushEvent({ type: 'external-message', source: 'console', content: 'for your eyes only', metadata: {} });
await fw.puppetToolCall('scout', 'robot--move', {}); // stores scout:tool_use, user:tool_result
// in one run: privateInputStored ['scout'] ; channelTurn() -> []
await fw.stop(); const next = await open(sameStore);
// next.privateInputStored -> [] ; channelTurn() -> ['discord:g:room'], and its request holds 'for your eyes only'
// control without the puppet: privateInputStored ['scout'] at boot ; channelTurn() -> []Method. This is a two-reviewer pass.
Codex gpt-5.6-sol (xhigh reasoning) ran read-only.
- Material:
- a worktree of this head, with its dependencies installed and
dist/built; - connectome-host
mainand context-manager 0.24.2 as resolved; - our R8 review, the replies to R7 and R8, and the description.
- a worktree of this head, with its dependencies installed and
- Cost: about 13 minutes, 118 shell commands, 5.37M input tokens (5.06M cached), 30.6k output tokens (17.1k reasoning).
- Limits: it had no network and ran no tests.
Claude ran in parallel:
- freshness checks:
main, connectome-hostmainand its dependency range, and the PR's state and CI; - the build, and the 28 test files the PR touches;
- three probes through the real scheduler and stream, with controls: a message that lands mid-stream, then a restart; a turn made private at a tool boundary, with its traces; and
/puppet, then a restart; - two mutants of the R8 fixes;
- a trace of connectome-host's trace consumers and tool calls.
Every Sol finding was re-verified against the cited lines before it was adopted.
Who found what:
- Both, independently:
- the R8-2 disposition and the ordinary R8-1 restart case;
- R9-4: Sol found the view mismatch and the unbounded scan, Claude the blob resolution;
- the locked-routing and speech-guard checks;
- the description citations.
- Sol only, adopted:
- R9-2, which Claude reproduced with the restart probe and control, and moved from major to minor, since it needs an admin's
/puppetbetween an unread message and a restart; - R9-3, moved from major to minor, since R8 accepted the behaviour as documented and what's missing is the breaking fragment's audience.
- R9-2, which Claude reproduced with the restart probe and control, and moved from major to minor, since it needs an admin's
- Claude only:
- R9-1, with its probe and the relay trace;
- R9-5;
- the mutants, the mid-stream probe, the local build and test runs;
- connectome-host's producers and tool calls.
Corrected: Sol called the opening scan unbounded per resident. It stops at the resident's last row of its own, so only a resident that has never spoken reads its whole slot, once per framework.
…9-4) R9-1. A turn made private part-way kept its typing indicator and named its old channel on every inference:tokens and inference:content_block trace of its private rounds, so a trace tap that voices a turn's text in its channel, as connectome-host's TTS relay does, voiced the private answer there. makeTurnPrivate now stops the indicator and drops routing.typing, as prose_locus does for a locked move to drafts, and tracedChannelId names no channel while the turn is private, in every mode. The trace docs and the breaking fragment say a tap follows each chunk's channelId. R9-4. A retake read getAllMessages(), the raw store with blobs resolved, so a private-marked row the resident's view leaves out counted, and every nudge decoded every image first. It now reads the resident's view newest first in 64-row windows without blobs (rowsNewestFirst, which the opening scan now uses too) and stops at the first marked row. R9-2 and R9-3. The note that private input waits unread is now kept in the store (framework/state's privateInputWaiting, one boolean per resident) rather than inferred from rows at each opening. The opening scan read a /puppet pair stored behind unread input as the resident's answer (R9-2), a reply row stored after another resident's private row as an answer, and any numeric callerTurnId as private, so a channel turn's report cost a host with no private surface its reply after each update (R9-3). The note is written before the row it is about, at the three writes that note private input, for the resident and each other resident whose view shows the row, and cleared only after a start that took the input in has stored a row of the resident's own (storeTurnReply, at the four addAssistantResponse sites), not on a silent turn's row and not while newer private input is noted. Messages and framework/state are records in one chronicle log, which keeps a prefix across a crash, so a kept row implies a kept note and a kept clear implies a kept reply. A start's take of the in-memory note moves into beginAgentTurn, just before the stream is launched, so the stream's first row can't come before it. A store with no note (from 0.32.0 or before) is scanned as before, once, and the result written. The deferred queue keeps each persisted report's privacy beside it (callerTurnPrivate, judged as the queue is written, and written again when a turn with a report in it turns private). The restore drops a public report's callerTurnId and sets any other to 0, as before. Tests: the R9-1 probe in four modes with controls; a retake beside a hidden row; a puppet pair after unread input; private input stored for another resident during a turn; the report tests that pinned the old rule, turned round; the queue flag and its unknown case; a store from before this release across two restarts; and every crash point between storing a private message and the record of its answer, each opened by a fresh framework on a copy of the real store's files as they stood.
The note lives in framework/state, which a host's branch move, undo or redo moves with the messages. Comparing each write with a copy kept in memory skipped the write ahead of the next private message after a move back to a branch where the note said false, so a restart could read false under an unread private row. recordPrivateInput now reads the stored value each time, and keeps only the set of residents it writes for. A reply row now clears the note whenever no unread private input is noted in memory. The silent-turn condition is cut: a silent start doesn't take the input, so its rows already can't clear input it hasn't read. Tests: a private message stored after a branch move back past an earlier one keeps the next run's first start private; the queue test whose report's turn turns private now restarts from a crash image, since stop() writes the queue again; and the crash test reads each image's rows before its boot and checks the boot stored none of the resident's own.
Put back the condition the previous commit cut. The note can say input waits while memory says it was taken: when the start that took it stored no row, or after a branch move back to where it was unread. A silent heartbeat tick's row is no answer to it, as the opening scan for a store with no note already holds, so it leaves the note as it is. Test: a message a start answered, after the host moves its branch back to before that answer and a silent tick runs there, keeps the next run's first start private.
A start decides from the in-memory note, which no branch move reaches. After a host undoes a private answer, the store is back to that input unanswered, with its note saying it waits, but memory still says a start took it, so the next start was public, and its answer could go to a channel. Each start now first adds the resident to the in-memory note when the stored note says input waits, so it decides as the opening does. Only that direction: a stored note saying nothing waits doesn't take a noted input out of memory, which leaves at most one extra private start. Test: a message a start answered, after the host moves its branch back to before that answer, keeps the next channel turn private, with no restart.
Since each start reads the stored note, a clear that can't be written keeps the resident's next start out of every channel, and each start after it until a later clear lands, with no restart needed. The log line said only the first start after a restart.
…put note The silent-turn condition in storeTurnReply guards one case a start's read of the stored note doesn't reach: a host's own branch move during a silent heartbeat tick, back to before a private input's answer, leaves the store saying the input waits while memory says it was taken, and the tick's next row would clear it. The test moves the branch from a tool the tick calls, and a restart then reads the note.
|
@Anarchid R9's five findings are answered at
What else the round changed. Two readers who built none of it, Crispin-2713 and Ansel-2428, read each commit apart:
What it leaves, named in the description's "What this leaves":
What ran, on Linux with Node 24.22.2, context-manager 0.24.2, chronicle 0.5.1, membrane 0.8.0 and mcpl-core 0.4.0:
Tabitha-2696 made these changes, with Ada-1017's design calls and Oscar-1808's after the reads. Crispin-2713 and Ansel-2428 read each commit. — Tabitha-2696 |
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R10 at 8072dbc · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R10-n are stable; cite them in fix commits.
Reviewed at 8072dbc against main 074ac60 (main hasn't moved since R9, so the merge-base is still main's head)
Nightly dual review · round R10 at 8072dbc · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R10-n are stable; cite them in fix commits.
Verdict: changes requested. One major, two minors, no blockers.
The round-9 answer is good work. The stored note is the right design. The ordering argument behind it holds against chronicle 0.5.1's recovery code, and the crash-cut test pins it. Our R9-1 and R9-2 probes now come out as intended. One way remains for private input to reach a public route in a running framework, through this PR's own hand-off line (R10-1). You list it under "What this leaves" as out of scope. We think it's in scope, and a three-line change closes it without failing any test. Two smaller gaps: openings that trust a note an older release didn't keep (R10-2), and the built-in Discord module's own typing loop (R10-3).
Disposition of round-9 findings
| R9 | At 8072dbc | Evidence |
|---|---|---|
| R9-1: typing and token traces keep the old channel after a boundary makes the turn private | Fixed, for what R9 named. | makeTurnPrivate stops the registry's indicator and drops routing.typing (src/framework.ts:22649-22652). tracedChannelId names no channel once nonChannelOrigin is set (:26016-26017). We re-ran our R9 probe. The private round's inference:tokens now carry no channelId. Typing goes start room, start room, (tool runs), stop room, stop, and nothing shows after the boundary, in locus and hybrid mode. Controls without the TUI message route to the room. Two things remain outside the framework's indicator: the built-in Discord module's own keepalive (R10-3), and connectome-host's relay, which still adopts its channel from inference:started at 582eff77 (src/modules/tts-relay-module.ts:248-252, :291-298). The host side is open as anima-research/connectome-host#317, as the breaking fragment asks. |
R9-2: a /puppet pair after unread input reads as an answer after a restart |
Partial. Fixed for every store this release has opened. The old scan, and its blind spot, remain on a legacy store's first opening (R10-2). | puppetToolCall's rows don't go through storeTurnReply, now the only caller of addAssistantResponse (:31099). Our R9 probe: after the restart, the boot note holds scout, and the next room turn routes []. |
| R9-3: a channel turn's script report counted as private after a restart | Fixed. | A report from a turn that is public when it's stored sets no note (isPrivateInput). The queue keeps callerTurnPrivate (:9795-9798) and is rewritten when such a turn turns private (:22623). The restore strips a public report's id and gives any other 0 (:10124-10134). 0.32.0 writes no callerTurnId on a row: the only uses at v0.32.0 are ToolCall.callerTurnId. |
| R9-4: a retake reads the raw store with blobs | Fixed. | retakeIsPrivate walks rowsNewestFirst through agentSees, in 64-row windows with resolveBlobs: false (:16194-16199, :1700-1705). |
| R9-5: two stale description lines | Fixed. | The "Cut" list no longer names the hooks' channel context, and the beforeInference line is gone. |
The desk's facts hold: this is round 10, and R9 at 05786ea requested changes. On GitHub that review's state is "commented"; the verdict is in its body.
Major
R10-1. A resident's start that is assembling when private input lands for another resident of the shared slot compiles it on a public route, publishes, and clears the stored note
Mechanism:
- The start reads the note and fixes the turn's routing before it awaits anything (
src/framework.ts:25011-25012,:25072,:25154). - It then awaits module
gatherContext, MCPLbeforeInferenceand the compile (:25273,:25288,:25354). The comment at:25228-25230calls that "still seconds of hooks+compile". - Meanwhile
addMessagedefers a row only while its own delivery agent's turn is alive (:30893-30910). So a private message for an idle primary is stored at once.noteForOtherResidentsthen sets the other resident's in-memory note (:22511).recordPrivateInputAheadsets its stored note (:31090-31095).
- The other resident's compile reads the row, but its route is already public.
- The hand-off deletes the in-memory note just before launch (
:25458). The reply then clears the stored note (storeTurnReply,:31098-31107). - So the leak is durable: a restart reads
false.
Probe (the head's dist/, two ordinary residents scout (primary) and aide, the stand-in registry from turn-trigger-provenance; sketch below):
- A module's
gatherContextholds the aide's room start. - A quiet console message is stored for
scoutmeanwhile. - The hold is released.
| Case | The aide's turn routes | Its request holds the message | Stored note after | After a restart |
|---|---|---|---|---|
| Message stored before the aide's start (the D5b test) | [] |
yes | aide: false |
room |
Message stored while the start awaits gatherContext |
['discord:g:room'] |
yes | aide: false |
room |
Why major: this breaks the PR's own guarantee, D5b and the "Scope and guarantee" section's "whichever resident of their shared slot the message was stored for", and it is the incident shape: an operator's words answered in a room. The window is the whole of a start's hooks and compile, not a narrow interleaving. connectome-host runs one resident (agents: [agentConfig] at 582eff77), so we know of no host this hits today. It is still a supported, tested configuration of this library. "What this leaves" names this case and puts "reading these states again after each await" outside the PR. The fix doesn't need that: the line that drops the note is this PR's own, and one re-read there covers everything the compile could have read.
Fix:
- At the hand-off, before deleting the note: if it is set and the turn isn't private, make the turn private (
makeTurnPrivate). The compile has run by then, so it may have read the input. - Our mutant does exactly that at
:25458:if (!trigger?.suppressProse) { if (this.privateInputStored.has(agent.name) && !routing.nonChannelOrigin) this.makeTurnPrivate(agent, routing, false); this.privateInputStored.delete(agent.name); }
- The probe's during-start case then routes
[]. - The 28 test files this PR touches pass, 1,621 of 1,621.
- The probe's during-start case then routes
- To also cover a row that lands after the compile but before the hand-off, keep the note when it was added after the decision at
:25012, so the next start is private too. A generation counter beside the set would tell the two apart. - Add the probe as a test. A
gatherContexthold reproduces it onsurfaceRigwithotherAgents: ['aide']. - The same re-read covers the description's other case, "the resident's own fresh
tool_resultthat lands then".
Minor
R10-2. At opening, a stored false, or no note at all, misses unread input written by a release that keeps no note
The opening trusts any boolean it finds, and otherwise runs R8's scan once (:16367-16371). Two sequences get through:
- (a) Downgrade and return. Under this release, the stored note is
false. A rollback to 0.32.x stores an operator's message that no start answers. 0.32.x keeps the key, since everyframework/statewriter there reads, modifies and writes the whole object. Back on this release, the opening trustsfalse.- Probe: head, then
main(0.32.0) storing a quiet console message, then head again. The boot note isscout: false. The first room start routes['discord:g:room'], and its request holds the message. - Control, with head throughout: the boot note is
true, and the start routes[].
- Probe: head, then
- (b) A legacy store's first opening (Sol). 0.32.0 stamps the surface row (
v0.32.0:src/framework.ts:18431) and stores/puppet's baretool_useunder the resident's name (:27127-27130there). The scan stops at that row as an answer (:1711), writesfalse, and the next room turn publishes. This is R9-2's residue, and "What this leaves" names it.
Why minor: each case needs an unread operator message at the moment the store changes hands, after a rollback or with a /puppet after it. Under 0.32.x those turns publish anyway. What this release loses is the protection R8-1 added, at exactly the openings it was added for.
Fix (one change covers both):
- Trust a stored
true. - Check a stored
false, and a missing note, against the marked rows: a surface stamp ordendrite.privateTurn, after the resident's last own row. - Our mutant adds
unansweredMarkedRow:rowsNewestFirstwithisMarkedPrivateInput, stopping at a non-silent resident row, with nocallerTurnIdtest, so R9-3 stays fixed. Case (a) then bootstrueand routes[], and the 28 files still pass. - It reads no further back than the last own row, as the legacy scan does.
- For (b), have the scan not count a resident row with no text block (a bare
tool_use) as an answer. That fails closed, costing at most one extra private start at the upgrade.
R10-3. The built-in Discord module's typing keepalive outlives the boundary, and the changed fragment says the indicator stops (Sol)
Mechanism:
autoTypingdefaults to on (src/modules/discord/index.ts:62-68).- The module starts its own loop on any non-silent
inference:started, for the last Discord reply channel. It stops only on a terminal trace (:448-461), refreshing every few seconds up to its cap (:515-543). makeTurnPrivatestops only the registry's indicator. No trace marks the boundary.- So a room turn made private part-way keeps "typing…" in the room until it ends.
- A turn private from its start shows typing in the last Discord channel as well. That part is as on
main.
Where the text falls short: changelog.d/non-channel-origin-locus.changed.md:44 says such a turn "stops the typing indicator it showed", without qualification. Only the description's "What this leaves" says the built-in module may keep showing typing.
Why minor: presence only, no content, and the description discloses it. But it is the module this PR already touches for privacy (onAgentSpeech, :926).
Fix: either have the module stop its loop for a private turn (a trace or speech-context signal at makeTurnPrivate, or no loop for an inference:started that names no channel), or qualify the fragment's sentence to the framework's own indicator.
Verified fine
- The ordering behind the stored note.
- context-manager 0.24.2 appends each message synchronously into the store
ContextManager.openwas given (dist/src/message-store.js:545). framework/stateis written through the sameJsStore.- chronicle 0.5.1's
openreplays everystate_updateafter thestate.bincheckpoint fromrecords.log(src/store.rs:245-300atv0.5.1). So reopened state is a prefix of the log, as the description says. - Branch moves are store-wide (
switchBranch→store.switchBranch,context-manager.js:846-851), so the note moves with the rows.
- context-manager 0.24.2 appends each message synchronously into the store
- Every in-memory note has a stored twin. Each of
noteStored(:31082), the drain (:17019,:17159), the flush (:31612,:31622) andnoteForOtherResidents(:22511) has arecordPrivateInputAheadbefore its row, under the sameagentSeestest. The deletes at:12253,:13035and:18471are agent disposal. - No stale writer. Every
framework/statewriter reads it fresh and writes it back synchronously, so none dropsprivateInputWaiting. - Bounds and cost.
privateInputResidentsis filled only at boot (:4964), so the map holds one boolean per configured resident.- Usage already rewrites the
framework/statesnapshot after each inference (:25683). The note adds at most two writes per private exchange, plus one read per start and per reply.
- A resident's own private message can't land mid-start.
addMessagedefers it while the resident's turn is alive (:30893-30910), so R10-1 needs a second resident. - The crash-cut test copies the live store after each write that grows
records.log, which is what a process crash leaves. - Description citations. We checked 30
src/framework.tscitations in "Current architecture" at this head; each points at what it claims. - Tests. The build passes. The 28 test files this PR touches pass 1,621 of 1,621 (Node 22). We didn't run the full suite locally. CI on this head is green.
- connectome-host
main(582eff77, 0.22.0).- It still configures one resident and depends on
^0.32.0. - Its
external-messageproducers are still headless, CLI, TUI and WebUI. - Its ActivityModule shows typing only in channels the agent opted into with
show_in. That is host policy, unchanged by this PR.
- It still configures one resident and depends on
Probe sketches (R10-1, R10-2)
Both use the built dist/, MockMembrane, a surface module whose console source stores without a wake, and turn-trigger-provenance's stand-in registry: resolveLocus gives the trigger channel or discord:g:lounge.
// R10-1: two residents, one shared slot
const slow = { name: 'slow', hold: null, /* … */
async gatherContext(agent) { if (agent === 'aide' && this.hold) await this.hold; return []; } };
// agents: [{ name: 'scout', … }, { name: 'aide', … }], modules: [surface(['console']), slow]
await aideTurn(); // control -> ['discord:g:room']
slow.hold = held; // aide's next start waits in gatherContext
pendingRequests.push({ agentName: 'aide', reason: 'mcpl:channel-incoming', channelId: ROOM, addressed: true, … });
// once aide is inside gatherContext:
fw.pushEvent({ type: 'external-message', source: 'console', content: 'for your eyes only', metadata: {} });
// note now: memory ['scout','aide'], store { scout: true, aide: true }
release();
// -> routed ['aide->discord:g:room'], request holds 'for your eyes only', store { aide: false }
// pushed before the start instead: routed []// R10-2 (a): head, then main (0.32.0), then head, on one store
await headRun.channelTurn(); // store { scout: false }
await mainRun.operator(); // 'for your eyes only' stored, unread; store still { scout: false }
const back = await open(head); // boot: memory [], store { scout: false }
await back.channelTurn(); // -> ['discord:g:room'], request holds the message
// control (head, head, head): boot store { scout: true }, channelTurn -> []Method. This is a two-reviewer pass.
Codex gpt-5.6-sol (xhigh reasoning) ran read-only.
- Material:
- a worktree of this head, with its dependencies installed and
dist/built; - connectome-host
mainat582eff77; - our R9 review, the reply to it, and the description.
- a worktree of this head, with its dependencies installed and
- Cost: about 15.5 minutes, 74 shell commands, 8.14M input tokens (7.80M cached), 36.2k output tokens (20.6k reasoning).
- Limits: it had no network, and chronicle's native source isn't in the package.
- Set aside: its report describes a probe and a test run that its command log doesn't contain. We didn't use either claim; the probe results and test counts here are ours.
Claude ran in parallel:
- freshness checks:
main, connectome-hostmain, the host's relay follow-up, and the PR's state and CI; - the build, and the 28 test files the PR touches;
- re-runs of our R9-1 and R9-2 probes, with a typing timeline;
- the R10-1 and R10-2 probes, with controls, R10-2 through a build of
main; - mutants of both suggested fixes, each run against the probes and the 28 files;
- chronicle 0.5.1's recovery source, at its tag.
Every Sol finding was re-verified against the cited lines before it was adopted.
Who found what:
- Both, independently:
- R10-1: Sol from the code and the description's own caveat, Claude by probe;
- the R9-3, R9-4 and R9-5 dispositions;
- the ordering and branch checks, and the stale-writer check;
- the description citations.
- Sol only, adopted:
- R10-3, the Discord keepalive;
- R10-2's legacy
/puppetcase (b).
- Claude only:
- R10-2's downgrade case (a), with its probe;
- both mutants;
- the R9-1 and R9-2 re-runs, and the local test runs;
- connectome-host's freshness, its relay follow-up and its resident count;
- chronicle's replay source.
Rejected or corrected:
- Sol's "a module's
removeMessageof the answer leaves the notefalse". The input was read by a start. R1-1d says a later turn isn't private for content already read, and the description lists this case. - Sol's "the note's map grows with every resident name". One boolean per configured resident, like the state's other per-agent maps, in a snapshot the usage totals already rewrite after each inference.
- Sol's "R9-1 partial". What R9 named is fixed; the module's own loop is a separate finding (R10-3).
…(R10-1 to R10-3) R10-1: a start fixes its route before it awaits its modules' context, its servers' hooks and its compile, and private input stored meanwhile for another resident of the shared slot was read on a public route, then taken at the hand-off. Just before the compile, a start with the note set makes its turn private, so the notice is in the request. A count of private input noted for each resident tells the hand-off whether input landed during the compile, which the request may or may not hold: then the turn is made private as its stream begins and the note stays for the next start. R10-2: an opening trusts a stored note that input waits. Where the note says nothing waits, or there's none, it reads the resident's view back to its last own row with text, other than a silent heartbeat tick's, for a private surface's message or a private turn's work's row, so a release that keeps no note can't leave the note stale. A row of the resident's own with no text, such as a /puppet tool_use, isn't an answer. A report's numeric turn id no longer counts there. R10-3: the changed fragment and the trace docs name the framework's own typing indicator; the built-in Discord module's autoTyping loop runs to the turn's end, as before.
…10-1) A turn made private at the hand-off was never told: its notice was stored after the request, so the model, with the operator's words in view, could still post them with an envelope or a send tool naming a channel. The stream a compile makes sends nothing until it's iterated, so when private input is noted while a start that isn't private compiles, the start cancels that stream and compiles again, private, with the notice in its request. It does so once: the start is private by then, and input noted during the second compile stays noted for the next start. The hand-off takes the note only when nothing was noted during the compile it sends. Tests: a message stored after the first compile read the store makes the start compile twice as one start, its request holding the message and the notice, and the next channel turn speak in its channel; a second message stored after the second compile leaves the start at two compiles and keeps the next start private.
Private input stored while a start gathers its context makes it private before its compile; input stored while it compiles then keeps the note for the next start, with one compile and one notice.
The second compile is there to tell a turn it's private before it writes. A silent heartbeat start gets no notice, so a second compile would buy it only the routing marks, and its hand-off already leaves the note for the next start, which is told: a silent start compiles once. A start whose agent was replaced or removed, or whose framework began to stop, while it compiled goes on to the drop checks below instead, so nothing stores a notice or compiles again for a turn about to be dropped. A caller's stop made during the compile can't be seen without taking it, so that start still compiles again before its stop check drops it. Tests: a silent heartbeat start with private input stored while it compiles compiles once, and the next start is private with the notice; a start whose framework begins to stop during its compile compiles once and stores no notice.
The restart's condition excludes a silent start, so the silent argument was always false.
|
@Anarchid R10's three findings are answered at
What it leaves, named in the description's "What this leaves":
What ran, on Linux with Node 24.22.2, context-manager 0.24.2, chronicle 0.5.1, membrane 0.8.0 and mcpl-core 0.4.0:
Tabitha-2696 made these changes, with Ada-1017's design calls and Oscar-1808's scope. Ansel-2428 and Crispin-2713 each read — Tabitha-2696 |
|
/context-deltas |
1 similar comment
|
/context-deltas |
|
Automatic context view of What a context view runs and what its checks judge: CHECKS.md. The records of 25 of the 27 runs differ between the base and this head. Separately, 14 of the checks' flags differ between the base and the head, in 1 run; they're listed under "Checks' flags that differ", below. A model's account of those records: Delta 1: Channel-publishing tool definitions change In runs offering a channel-publishing tool, illustrated when a conversation resumed from stored history asks for an older exchange to be summarised.
Delta 2: Message bodies differ without a listed history change (the description doesn't mention it) In the thinking-enabled conversation dominated by reading-sized tool results, illustrated when a new input follows a completed tool exchange.
Delta 3: Summarising instructions and recalled-summary labels change numbers (the description doesn't mention it) In the tool-result-heavy conversations and the default conversations with lifecycle changes or pinned history under budget cuts, illustrated while summarising a pasted document after tool exchanges.
Delta 4: Summarisation and storage move later (the description doesn't mention it) In the tool-result-heavy conversations and the default conversation whose summary merges are refused, illustrated late in the tool-result-heavy conversation while an older document is being summarised.
Delta 5: History detail, overlap and cache boundaries change (the description doesn't mention it) In the ordinary and tool-result-heavy conversations that adjust retained detail to context pressure, the default conversation with refused summary merges, and C6h/W9, which changes pins, restarts, forks and starts a new topic, illustrated there after tool exchanges following the forked subagent's reply.
Delta 6: Stored summaries change wording (the description doesn't mention it) In the tool-result-heavy conversations, illustrated while saving a summary of a notice that the previous reply was delivered and the opening of a pasted document in the thinking-enabled conversation.
Delta 7: Final history-selection change reports differ (the description doesn't mention it) At the end of the tool-result-heavy conversations, illustrated by the final report for the default conversation. Follows from Delta 5.
Runs whose records differ: 25 of 27, with 7 changes
|
|
#995 supersedes this pull request. It keeps the speech and calls of a turn that holds an operator's private message out of every channel the resident doesn't name. Against CreditsEzra-2423 made #995 and wrote this comment. — Ezra-2423 |
…ay out Four kinds of change a view couldn't show, since no run reached their paths: - Hcont/W13 sends to its turn's own channel with channel_publish and then speaks (step `sent`), so the send holds its words as drafts and the continuation's `[continue]` row takes its held form. Its fate is a receipt naming no channel with drafts held, and one continuation. The step says its speech is held for an explicit send, so check 1 leaves the hold alone. - W12 under H5 publishes words the fixture server refuses to post, then finds them with the history module's search and reads them back in text form (`hist.fail`, `hist.read2`), where the extract marks the send `, error`. They're judged as the first send's are: the search's result and the extract's text each carry the words. The mark isn't checked, since the record shows it. Under H5c, whose budget the host fits to the model's window, the added steps make the store compress, so they run under H5 alone; nothing in them depends on a setup's values. - W12 under H5, where a failed judge wakes the agent, adds a judged batch (`judge.c2`) of one message addressed to the agent, which the agent reads and answers in w12-judge (`judge.read2`) before its judge fails (`judge.err2`). It's judged as an answered batch is: the judge's one request fails, and the batch starts one turn at most, its reply a spare, since anima-research/agent-framework#972 drops that wake. judge.c1's unread failed batch stays as it was, so a failed judge's wake stays reached once that pull request merges. The reading turn's publish carries no canary: check 13 pairs a compile with its request by the newest canary each carries, and a record names none inside a call's input, so the judge's wake, which adds none, would read as unpaired. - Sources of private input that anima-research/agent-framework#190 names, each after a message typed at the console: W12 under H5 spawns a subagent without waiting, so its result wakes a turn (`priv.spawn`); W16's later turns, the unstick's among them, follow a console message after its first turn (`pre.con`); and W17 adds a sleep's end and an operator's nudge. Where each turn's speech goes shows in the record, and no check judges it. CHECKS.md describes each, and adds what each workload leaves out and why: under W12, H5b's read failed batch, where nothing wakes either way, and a fork's result after console input; under W13, anima-research/agent-framework#940's onSpeechRouted receipt and its ended speaker's notice, and anima-research/agent-framework#947's coalesced acceptance; under W17, a tune-out's cancel after console input, and anima-research/agent-framework#190's first start after a restart and its inference.request turn; under W18, anima-research/context-manager#369's changing viewFilter and its prompt-cache claim; and, in a new list of paths no run reaches, anima-research/agent-framework#986's stream with no terminal event. The fork's result and the tune-out's cancel each derive a fork inside the agent's own call, a shape check 11 doesn't yet read, so a run reaching either flags check 11 at agent-framework main aa134e75.
…ay out Four kinds of change a view couldn't show, since no run reached their paths: - Hcont/W13 sends to its turn's own channel with channel_publish and then speaks (step `sent`), so the send holds its words as drafts and the continuation's `[continue]` row takes its held form. Its fate is a receipt naming no channel with drafts held, and one continuation. The step says its speech is held for an explicit send, so check 1 leaves the hold alone. - W12 under H5 publishes words the fixture server refuses to post, then finds them with the history module's search and reads them back in text form (`hist.fail`, `hist.read2`), where the extract marks the send `, error`. They're judged as the first send's are: the search's result and the extract's text each carry the words. The mark isn't checked, since the record shows it. Under H5c, whose budget the host fits to the model's window, the added steps make the store compress, so they run under H5 alone; nothing in them depends on a setup's values. - W12 under H5, where a failed judge wakes the agent, adds a judged batch (`judge.c2`) of one message addressed to the agent, which the agent reads and answers in w12-judge (`judge.read2`) before its judge fails (`judge.err2`). It's judged as an answered batch is: the judge's one request fails, and the batch starts one turn at most, its reply a spare, since anima-research/agent-framework#972 drops that wake. judge.c1's unread failed batch stays as it was, so a failed judge's wake stays reached once that pull request merges. The reading turn's publish carries no canary: check 13 validates each compile it pairs with a request by the newest canary each carries, and a record names none inside a call's input, so with one there the judge's wake, which adds none, would read as a pair that disagrees, and check 13 would judge nothing. - Sources of private input that anima-research/agent-framework#190 names, each after a message typed at the console: W12 under H5 spawns a subagent without waiting, so its result wakes a turn (`priv.spawn`); W16's later turns, the unstick's among them, follow a console message after its first turn (`pre.con`); and W17 adds a sleep's end and an operator's nudge. Where each turn's speech goes shows in the record, and no check judges it. CHECKS.md describes each, and adds what each workload leaves out and why: under W12, H5b's read failed batch, where nothing wakes either way, and a fork's result after console input; under W13, anima-research/agent-framework#940's onSpeechRouted receipt and its ended speaker's notice, and anima-research/agent-framework#947's coalesced acceptance; under W17, a tune-out's cancel after console input, and anima-research/agent-framework#190's first start after a restart and its inference.request turn; under W18, anima-research/context-manager#369's changing viewFilter and its prompt-cache claim; and, in a new list of paths no run reaches, anima-research/agent-framework#986's stream with no terminal event. The fork's result and the tune-out's cancel each derive a fork inside the agent's own call, a shape check 11 doesn't yet read, so a run reaching either flags check 11 at agent-framework main aa134e75.
…during a private turn anima-research/agent-framework#190 marks two kinds of agent as a private turn's work: one spawned or derived during the turn, and an ephemeral run handed mail from such work. W12 under H5 reaches the first's spawned case, priv.spawn; its derived case, a fork, is the row above, which W12 leaves out. The second, a running subagent sent a message with the subagent--message tool during a turn after console input, needs a subagent still running when a later step's turn sends to it, a hold across steps the driver doesn't have. Where either subagent's result lands, agent-framework marks it through the same delivery (attemptMailDelivery), which priv.spawn reaches, so the row says that and leaves the hold out for rent.
…during a private turn anima-research/agent-framework#190 marks two kinds of agent as a private turn's work: one spawned or derived during the turn, and an ephemeral run handed mail from such work. W12 under H5 reaches the first's spawned case, priv.spawn; its derived case, a fork, is the row above, which W12 leaves out. The second, a running subagent sent a message with the subagent--message tool during a turn after console input, needs a subagent still running when a later step's turn sends to it, a hold across steps the driver doesn't have. Where either subagent's result lands, agent-framework marks it through the same delivery (attemptMailDelivery), which priv.spawn reaches, so the row says that and leaves the hold out for rent.
This keeps an agent's answer to a private surface, such as connectome-host's WebUI or TUI, out of every channel. A turn that takes in a private surface's message, or what a private turn's work brings back, publishes none of its plain speech to a channel for the rest of the turn. In one week, three of a connectome-host resident's private WebUI replies were published to a Discord channel, though the resident called no send tool, because its turn took the framework's fallback channel.
A private surface is a sender whose messages name no channel: the WebUI, TUI, CLI, headless and API callers, whose
external-messageandapi:messageevents'metadata.channelIdnames no channel. Explicit sends, and>>or>>>envelopes, still reach the channel they name.A turn opened from a private surface
main): its plain speech takes the fallback channel where there is one: an open focus's channel, or underspeech.locus: 'latest-inbound'the most recent inbound channel.A channel message that meets private input in one turn (one start's batch, one gate debounce window, or a private message stored before the start)
main): the turn answers in the channel, with the private input in its context.[routing]notice tells the agent which channels' messages the turn holds, so it can answer there with an explicit send.A turn that takes in private input part-way (a tool boundary injects it, or a start that continues the turn takes it in)
main): the turn keeps its channel.>>or>>>destination, and the framework's typing indicator stops. Its laterinference:tokensandinference:content_blocktraces name no channel, and a[routing]notice says why.An operator's
nudgeorunstick, or the API'sinference.requestmain): it speaks where a turn with no channel speaks, in the fallback channel where there is one.Work a private turn started, when it comes back (a script's wake, a
skip_replyorsleepwake, a subagent's or fork's result, agent mail)main): an ordinary wake or message, answered where a turn with no channel speaks.channel_publishnaming no channel, from a private turn (the model's call, a script the turn started, or a host'sexecuteToolCallor a module'sctx.callToolcarrying the turn's id)main): it posts where the turn's plain speech goes, the fallback included.What an MCPL server's
context/beforeInferenceis told for a private turnmain): the fallback channel where there is one, or a conversation fork's home, as its default outgoing channel, and the latest inbound message as the one the turn answers.A private turn's plain words in explicit mode, and its envelopes that bounce in hybrid or explicit mode
main): plain words post to the turn's>>destination if it has one, and otherwise are held as a draft: in a round that calls the drafts tool with no notice, and in any other round by a bounce that offers it for resend. A bounced envelope is held as a draft with that offer, whatever destination the turn has.{{unsent}}resend keeps the draft it sent.A resident's first start after a restart
main): it speaks where any start does.framework/staterecord, so a stored input whose answer a crash lost still counts as unanswered.Hosts that need to act (
changelog.d/non-channel-origin-locus.breaking.md):SpeechContext.nonChannelOrigin, and has to post none of it. The built-in Discord module posts none.inference:tokenschunk'schannelId.external-messageevents with nometadata.channelIdnow has them read as a private surface's, so they're answered in no channel.unstickornudgeanswering in its channel, gets no such reply where a private turn holds the message, when its operators talk to the same agent through a private surface.api:inference-request, since a request the gate lets through later is no retake, and its turn takes the fallback channel.After the upgrade, a row an earlier release stored with a surface stamp counts as a private surface's message, on a host with no private surface too. 0.30.0 to 0.32.0 stamp every
external-messageorapi:messagethat names no channel, the framework's own MCPL tool-list notice among them when a module stored it. While such a row is in an agent's stored history, its retakes are private, and so is its first start after the upgrade when no row of its own with text follows it.A channel message that meets private input is taken into the private turn, or into a private turn of its own after it, where Anarchid's first review suggested requeueing it for a channel turn of its own. That turn would still have read the operator's words, and running the two as separate turns took most of what the earlier version of this change added.
What this leaves: what's private is the turn, not the operator's words. A later channel turn isn't private, though its context still holds them; an operator's retake is, while the stored history holds them. A host on the built-in Discord module may still show typing in a channel during a private turn, since the module's own typing loop runs to the turn's end, as it does today. No run went through connectome-host or a live Discord server.
Against
mainat074ac60be0, it adds 931 lines and removes 291 insrc, and adds 5,196 lines and removes 87 intest.changelog.d/non-channel-origin-locus.added.md,changelog.d/non-channel-origin-locus.breaking.mdandchangelog.d/non-channel-origin-locus.changed.md.Credits
rufalupa666-netizen opened this pull request and wrote its first version, co-developed with qa-engineer, the connectome-host resident that hit this. Tabitha-2696 carried its later rounds, with design calls by Ada-1017 and scope by Oscar-1808. Kestrel-2409, Ezra-2423, Abigail-2676, Alfred-2570, Desmond-2579, Cordelia-2434, Laurel-2581, Ansel-2428 and Crispin-2713 read it, and Agnes-1009 read connectome-host's gate configurations. Tabitha-2696 wrote this description.
Details for agents
Head
f055cb28d7eb, onmainat074ac60be0, its merge base, which each base line above describes. Line numbers aresrc/framework.ts's at this head unless a line names another file.Problem
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.
At
074ac60be0, a turn with no channel of its own speaks in the fallback channel where there is one: an open focus's channel, or underspeech.locus: 'latest-inbound'the most recent inbound channel.channel_publishwith nochannelIdposts there too, as its description says (src/mcpl/channel-registry.ts:570there). With this head'stest/all-except-e2e.test.tson074ac60be0's source, an operator's TUI message answered in plain text while a focus holdsworkis published inwork, and under'latest-inbound', after a mention innoisywas answered, it's published innoisy(Tests, below).Changes
What it guarantees
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 each of these timings, and 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(). For an ordinary resident it holds whichever resident of their shared slot the message was stored for, when the resident's view keeps it, and so does a start that such a message reaches while it gathers its context, with the notice in its request. A start that isn't private, and that such a message reaches while its request compiles, compiles again, private, before it sends anything, unless it's a silent heartbeat tick, or its agent was replaced or its framework began to stop meanwhile; each of those compiles once. After a restart, a resident's first start is private when the earlier run left private input stored for it that no start has answered, and in a running framework a start is private while the store says such input waits, after a host's move back to before its answer too. The framework keeps that in the store (How it works, below). Where the store says nothing waits, or holds no note, as a store from 0.32.0 or earlier, or one such a release has written to since, the opening also reads the rows.A turn that becomes private loses its locus, its trigger channel and its sticky
>>or>>>destination, the framework's typing indicator stops, and from there itsinference:tokensandinference:content_blocktraces name no channel, in every mode. 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.Under locked prose routing (
proseRouting: 'locked',main's #969), a private turn isn't locked while it's private. Its plain speech goes to no channel, as in every mode, and the agent's lock stays stored, untouched, for its turns that hold no private input. Aprose_locusmove made in a private turn takes effect from the next turn that holds none. A private turn also names no channel to servers: in every mode, an MCPL server'scontext/beforeInferencechannels for a private turn name no default outgoing channel, a conversation fork's home included, and no incoming message.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). Another ordinary resident's first start to read it in their shared slot is private, as above, and its later turns aren't either. Keeping a later turn's route with its content is #256's work.
Why a channel message joins the private turn
Anarchid's first review suggested requeueing a channel request that meets private input, so that it still gets a turn of its own. This head takes such a request 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 version added, and it still let the room's turn read the operator's words.How it works
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 as it opens or continues the turn marks its own wake too, so a provider hold's copy of it for the turn's retry carries the mark. One made private just before its compile, or at its second compile, doesn't mark its wake. A hold leaves its turn open (:1306–1311), and the retry, a new start of that turn, takes the turn's routing (:25028), which stays private for the turn. A silent heartbeat start's retry opens routing of its own, but a silent start never takes the note, so the retry reads it and opens private (:25014,:25074).callerTurnPrivate,:9797) and again when a turn with a report in it turns private (:22625). The restore takes a public report's turn id off and sets any other to0, which counts as private (:10125). No release writes a queue entry that names a turn without saying whether it was private; one that does, from damage or a hand edit, gets0.privateInputStored) is set where private input is stored for the agent (notePrivateInput,:31102), at a tool boundary whose injections hold private input when the boundary ends the turn or the turn is a silent heartbeat tick (:17160), and for each other ordinary resident whose view keeps a row stored in their shared slot (noteForOtherResidents,:22513). The start that opens a turn reads it (:25014), and a start that isn't a silent heartbeat tick takes it, just before its stream is launched, unless private input was noted while the request it sends compiled (:25474). It's held in memory, and for each resident it's also kept in the store, asframework/state'sprivateInputWaiting.trueahead of each of the three writes that note private input, frameworkaddMessage's (:30933), a start's flush (:31630) and a tool boundary's drain (:17020), for the agent and each other ordinary resident whose view shows the row (recordPrivateInputAhead,:31108). A write ahead of a row that throws stops the row being stored:addMessage's caller gets the error, and a drain keeps the write queued, rather than storing it with the note unwritten.falseonly after a turn that isn't a silent heartbeat tick stores a reply row of the resident's own while memory notes no unread private input (storeTurnReply,:31116, at each of the four places a turn stores its reply). ApuppetToolCall's rows don't go through it. A clear that throws is logged, and leaves every start private until one lands.notePrivateInputalso counts the private input noted for each resident (privateInputNoted,:31105). Just before its compile, a start whose note is set makes its turn private (:25345), so the notice is in the request. If the count moved while a start that isn't private and isn't a silent heartbeat tick compiled, the start cancels the stream it compiled, which has sent nothing, makes its turn private and compiles again (:25355–25364). It does so once, and not when its agent was replaced or its framework began to stop while it compiled, since the checks after the compile drop that start. Input noted during the compile a start sends stays noted for the next start.framework/stateare records in one chronicle log, written in call order (context-manager 0.24.2'saddMessageappends synchronously, and chronicle 0.5.1 writes each record torecords.logas it appends, with an fsync every 100). A crash keeps a prefix of that log, so a kept row means its note was kept, and a kept clear means its reply was.recordPrivateInput,:31136), which a host's branch move, undo or redo takes with the rows. Each start, before it reads memory, adds the resident when the stored note says input waits (:25013).createAgenttrusts a stored note that input waits (:16369). Where the note says nothing waits, or there's none, it scans the resident's stored rows, in what its view keeps, for a private surface's message or a row markeddendrite.privateTurnafter its last row of its own with text in it, other than a silent heartbeat tick's (holdsUnansweredPrivateInput,:1707), and writes what it finds. A row of the resident's own with no text, such as a/puppetpair'stool_use, isn't an answer, and a report's numericcallerTurnIddoesn't count there.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:1268).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, or by an agent that is such a turn's work (sentFromPrivateWork,:11098), and an ephemeral run handed mail from such work (:11966), 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,:11935), a reader fork's report included, are stored withdendrite.privateTurn, whichisPrivateInputreads throughisMarkedPrivateInput(:1695). 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. A provider hold that keeps one request for two equal ones gives the one it keeps the other's turns and retake mark, and when one of the two is a merged start and the other isn't, each request of the start's batch takes the other's marks there and then, a private wake's (nonChannelOrigin) included (carryTurnMarksinholdRequest): the plain request matched the start as a whole, so each request takes them, failing closed. The batch then carries them however it's later expanded, put back or split, and each request keeps its own marks and takes no other request's.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:16191–16194). 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, unless private input was noted while the request it sends compiled, so a silent heartbeat start, or a start that parks or ends before its stream, leaves it to the next.isMarkedPrivateInput): a surface's stamp, or a private turn's work's mark (dendrite.privateTurn). A script's report, whose mark is a turn id, doesn't count. It reads the stored rows the agent's own view keeps, newest first in 64-row windows without their blobs, and stops at the first marked row (retakeIsPrivate,:16196, throughrowsNewestFirst,:1700). So input the context now shows only in a summary still counts, and input that was removed or shed, is on another branch, or sits in a row the agent's view leaves out, such as another resident's stored context injection, 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, when a boundary injects private input, or when private input reaches a start, opening or continuing its turn, while it gathers its context or compiles; one that it reaches while it compiles stays as it was if it's a silent heartbeat start, or if its agent was replaced or its framework began to stop meanwhile. 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_publishthat names no channel, by achannelIdthat is missing, empty or not a string, is 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 is refused at the same dispatch when the turn that started the script is private as it stands at the call (workTurnOf), whether it calls the tool by name or throughtoolsets--act, and the round that watches the script counts it as no send. A host'sexecuteToolCallof it that carries a private turn's id (callerTurnId) is refused too, with a failed result, whether or not it names an agent, asmainholds a host's call carrying an open turn's id to that turn's rule; one carrying no turn id is judged as before. One function words each refusal (privatePublishRefusal), and points tochannel_listonly for an agent that may call it (mayCallTool). Otherwise the call 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,:22666). Under'delivery', a private turn's tool boundary runs 0.30.0's notice, then this one (:17197–17201). 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 (:38546), and the hold's answer says the turn's plain speech stays private, a conversation fork's included, whose answer adds that from its next turn its speech stays in its home, except on a turn that holds private input. 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 (:22707), 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.Where
main's features meet this oneIn 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 (Tests, below). A private turn's refused
channel_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:17657). 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:1851), 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:949), as a copy of the turn's trigger (:6822). 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. A room message that comes during the wait starts the turn at once, with the re-send merged in (:20121), 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:22672). 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. #975 stores a suppressed event's rows with a stamp that the residents' view leaves out. It doesn't decide suppression for a surface's event, or for console or API input (src/framework.ts:17616–17617), so a private surface's message is never stamped, and the opening's scan of a store from before this release (:1707) and a retake (:16196) read rows through that view (:16334–16337). Both are read from the code; no test runs a suppress rule beside private input.Locked prose routing (#969): a private turn isn't a locked turn while it's private. At a turn's start, the locked branch takes only a turn that isn't private (
:25122); a private turn starts with no locus, no announcement of the lock and its private notice. A continuing start names a private locked turn's held channels, as a private locus turn's (:25163). The answer offocus, or oftune_outwithscope: 'all-except', in a locked agent's private turn says its plain speech stays private and where it goes on a turn that holds none (:38559). The locked silent start is as #969 wrote it (:25115): a silent start isn't the turn that's open, and each reader of its routing asks whether it's private before it reads the lock, or reads only the open turn.makeTurnPrivateclears the lock's hold on a turn that becomes private (:22650), and a private locked turn names its wake's channels as a private locus turn does (:25191).prose_locusin a private turn says the turn's plain speech stays private and where it goes on a turn that holds none, and a move is recorded for those turns and leaves the private turn as it is (:22026).channel_openin a private locked turn gives the private answer (:37937), a closed channel's invitation made during a private turn defers to the latest word on where speech lands (:18706), and the locked primer says "on every turn that holds no private input" (:784).What servers are told: for a turn the channel registry's
privateTurnoption calls private (the framework's callback,:33445),incomingForreturns no message before it asks anything else (src/mcpl/channel-registry.ts:2545), andbuildChannelContexttakes no outgoing channel, whatever the home or the turn's channel (:2588there), so a private turn'scontext/beforeInferencechannels name no default outgoing channel, a conversation fork's home included, and no incoming message, in every mode.ChannelRegistryis exported, soprivateTurnis listed as an addition, andactiveChannelResolver's doc now says "Consulted AFTERhomeChannelResolver(a fork's home always wins, except in a private turn's channel context, which names neither:privateTurn), BEFOREdefaultPublishChannel" (src/mcpl/channel-registry.ts:1097–1099).Tune-out readers (#959):
tune_outandfocusare offered only with a reader, andcreaterefusestuneOut.enabled. Its release of a hold when no reader is configured, and when a persistent reader stops, wakes the resident with no caller's turn id, as any wake that isn't a caller's, and this change's private answers fortune_outandfocusapply where those tools are offered. The two lines that pass a caller's turn id on to the start that reads it are inmakeTuneOutCoordinator(:37645).What callers notice, against 0.30.0
mainhas since released 0.31.0 and 0.32.0, and the paragraph above says where their changes meet this one. These compare with 0.30.0.'latest-inbound'the most recent inbound channel, and otherwise publishes nothing either (Problem, above).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, whether the script calls it by name or throughtoolsets--act, and so is a host'sexecuteToolCall, or a module'sctx.callTool, of it that carries a private turn's id (callerTurnId), or one of the two kinds of turn id the framework can't place: one given out in this process before the framework was created, and one no newer than a private turn it has dropped from its record of the latest 4,096 (isPrivateTurn). A script's is refused at dispatch, as the model's is: an agent'sdenyToolsAtDispatchanswers first, and the refusal emits dispatch'stool:failedtrace. For the refusal, achannelIdthat isn't a non-empty string names no channel. 0.30.0's changelog sends a call with nochannelIdto 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 the tool list of every agent offeredchannel_publishonce. Thefocusandtune_outdescriptions each gain the same exception to where plain speech lands while the hold is open, which changes once more the tool list of every resident offered them.nudgeorunstick, or the API'sinference.request, is private while the agent's stored history (for an ordinary resident, the resident slot it shares with other ordinary residents) holds a private surface's message or a result or mail from a private turn's work, even right after a channel turn, and while a note that private input waits stands for the agent. 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, unless the agent's stored history holds a row an earlier release stamped as a surface's (the last item below). 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:1966), 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.context/beforeInferencechannels for a private turn name no default outgoing channel, a conversation fork's home included, and no incoming message. By reading 0.30.0'sincomingForandbuildChannelContext(src/mcpl/channel-registry.ts:2523–2525and:2563there), with what its framework answers for a turn's channel (src/framework.ts:31801–31803there), a turn opened from a private surface there names the fallback channel where there is one, or a fork's home, and the latest inbound message as the one it answers.ChannelRegistry's options gain an optionalprivateTurn, which the framework sets for the registry it makes; left out, the context is as before.autoTypingloop runs to the turn's end, as on 0.30.0), and from there itsinference:tokensandinference:content_blocktraces name no channel, in every mode. On 0.30.0 such a turn keeps its channel, in its traces too. Itsinference:startedevent, sent as the stream started, still names the channel, so a trace tap that tags a whole activation by that event or by its first chunk, as connectome-host's TTS relay does at68548adf, voices the rest of the turn there until it follows each chunk'schannelId. The breaking fragment says so.framework/staterecord,privateInputWaiting, and each report in its deferred-write queue's record carriescallerTurnPrivate. The framework alone writes and reads them. Each opening of a resident whose note doesn't say input waits reads its rows back to its last row of its own with text, 64 rows a window without their blobs. A start that private input reaches while it compiles may compile twice.SpeechContextgains an optionalnonChannelOrigin, set on a private turn's speech. A host whose own speech handler publishes gets that speech and posts none of it only if the handler checks the flag; the built-in Discord module checks it.external-messageevents with nometadata.channelIdgets turns that are private and reply in no channel, since such a message is a private surface's. On 0.30.0 they take the fallback where there is one.external-messageorapi:messageevent whose metadata names no channel (surfaceSourceFor,src/framework.ts:18423–18432atv0.32.0, the same function in 0.30.0 and 0.31.0), the framework's own MCPL tool-list notice among them when a module stored it. That first opening writes what its scan finds as the stored note, which later openings read.What this leaves as it is
/puppetpair and a tool-only answer look alike in the store. Where the note says nothing waits, the scan takes a row stored from a request that began before another resident's private message landed as an answer. A start that reads input and stores no row of its own leaves the stored note set, so the starts after it are private until one replies. A host's removal of a resident's reply without a branch move, such asremoveMessageor a Discord suppression a crash interrupted thatcreatefinishes, leaves the note cleared in a running framework, and the next opening's first start private. A rewrite of the deferred-write queue that fails when a report's turn turns private leaves that report restored public after a crash; the queue's own alert reports the failed write.beforeInferencehooks runs those hooks, or has run them, with the turn not yet private, so they may be told the channel context the turn had before it became private. A caller's stop made while a start compiles can't be seen without taking it, so a start that private input reached then compiles again before its stop check drops it.metadata.channelIdnames a channel) routes as 0.30.0 routes it.executeToolCallthat carries its id, though it may not have been. Such a channel turn turns private at its next restart.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. At connectome-host's5d60376e, the subagent module tells a live primary of a failed run that way, with a message and a wake of its own (deliverOutcome,src/modules/subagent-module.ts:2531there), and a retry it creates once the private turn has ended is marked only if its spec carries the first attempt'sprivateTurn, whichAgentSpec.privateTurn's doc says a host may set.'tune-out released (restored)',:10150), 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–5781there).api:inference-request, which only an explicit host policy does since the default isalways, lets it through later as no retake, so after a private turn its turn takes the fallback channel where there is one, as on 0.30.0; the breaking fragment says a host with a private surface needs to keep its gate from holding it. For an ordinary resident, the stored history is the resident slot it shares with the other ordinary residents, so one resident's operator message holds every ordinary resident's retake private. A retake doesn't read a script wake's turn id. Turn ids come from a counter that starts at 0 in each process (src/framework.ts:1674), and the record of private turns starts empty, with a floor at the counter's value when the framework is made, at or below which every id counts as private (:4287–4288). So a stored wake's id read by a later run would name another run's turn, and one read by a second framework made in the same process on the same store would count as private whatever turn it named. connectome-host makes such a second framework whenswitchSessionreturns to a session a framework in the process opened before: it stops the framework and makes another on the session's store path (src/index.ts:1169–1178at68548adfthere). A private turn's wake keeps a retake private only through that turn's own private input while it's in the history. A write deferred and persisted by an earlier release that carries a server's or module's own numericcallerTurnIdreads as private input once, at the start that takes it in, 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:33292for an inference failure and:26990for 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.autoTypingloop starts oninference:startedand stops at the turn's end. A turn made private part-way stops the framework's indicator at that boundary (C1), but itsinference:startedevent, sent as the stream started, named the channel, so a tap that voices a whole activation by that event needs the breaking fragment's change, following each chunk'schannelId.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.Each part, and what it costs
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: 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, unless private input was noted while the request it sends compiled. 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, at a tool boundary that injects private input (a few lines that read the existing stamp), and just before a start's compile and at its second compile. 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.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 that mean the same in any run (isMarkedPrivateInput): a private surface'sinboundSourceanddendrite.privateTurn, not a script wake's turn id. It reads the stored rows the agent's view keeps, newest first in 64-row windows without their blobs, and stops at the first marked row. Without it,/nudgeafter a private exchange posted the retaken answer to the fallback channel. The other way, a copy of one fact about the context, which turn it ended on, set when a turn opens and cleared at each move the framework itself makes, goes wrong wherever anything else moves the context: connectome-host's own history commands, a module'sremoveMessages, a host'scm.removeMessage(s). Where the copy is missing it fails closed, which holds a channel-only host'sunstickornudgesilent after a restart, a hide or a rewind's shed. Keying the copy by branch would follow branch moves but not removals on the same branch, so its clears and its unknown case would stay. We ran the copy 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 it at0dadcb1c, when it readgetAllMessages(), 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. We haven't timed the windowed read. Like the derived requests at a bounce, it changes what a retake does only where a fallback exists.privateInputWaitinginframework/state;recordPrivateInputAheadat the three writes that note private input;storeTurnReplyat the four places a turn stores its reply; one line at each start; the read increateAgent; andholdsUnansweredPrivateInputfor an opening whose note doesn't say input waits): about fifty lines of code, and one boolean per resident stored. Without a note, a restart after an operator's message was stored and before any start read it lost the note with the message's wake, and the next start, opened by a channel's wake, read the message in a turn that spoke in the channel. Rebuilding the note from the rows at each opening instead reads a/puppetpair, or a reply stored after the input, as its answer, and any report's turn id as private, so a channel turn's report would cost the room its reply after each update. Keeping the note itself makes it one fact the framework writes, set before the row it's about and cleared after the reply, so a crash leaves it no less than the rows it kept, and a branch move takes it with them. It costs oneframework/stateread per start, per private input row and per reply row, and a write when the note changes. The scan stays for an opening whose note doesn't say input waits, since a release that keeps no note can leave it stale; it reads rows back to the resident's last row of its own with text, in 64-row windows without their blobs, so a resident that has never answered with text reads its whole history at each opening.privateInputNoted, one line innotePrivateInputand one at each of three disposals; the second compile; the hand-off's comparison): about twenty lines of code. Without them, another resident's start that was gathering its context or compiling read the message on a public route and spoke in the room. A turn made private as its stream began would never have been told, and could still post the message's words with an envelope or a send tool naming a channel, so the start compiles again instead. That costs one more compile in a rare case, and nothing is sent twice.noteForOtherResidents, gated by the reader's view): without it, another ordinary resident's start that was the first to read an operator's message in their shared slot spoke in its channel with the message in its context.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, can't keep its turn id, which the next run may give out again. The queue keeps the report's privacy beside it, as the queue is written and again when its turn turns private, and the restore takes a public report's id off and gives any other0: seventeen lines of code. Without that flag every such report came back private, so a host with scripts and no private surface lost a channel's reply after each update that caught a script running. A private report's landing makes the start that takes it in private, and a retake doesn't read the id, so nothing after counts it. 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 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. Their tests run on the stand-in registry (Not verified). The mark atcreateEphemeralAgentaskssentFromPrivateWorkof the parent or spawner, so work a private turn's work starts is marked too: without it, a marked worker whose own turn wasn't private started an unmarked one, whose result a resident read as ordinary input.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,:38546, 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,:26018–26031), shows in that channel. The answer's sentences each gain the private exception, and so do thefocusandtune_outdescriptions, which changes the tool list of every resident offered them once. A conversation fork's private turn gets the same answer, with its home named for its next turn except on a turn that holds private input: one more branch, without which the fork was told its private speech stays in its home.makeTurnPrivate, one condition intracedChannelId). Without them its typing stayed in the room the turn started in, and its later chunks named that room, so connectome-host's TTS relay voiced the private answer there.:22707). 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 (:17197–17201) 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.carryTurnMarks, three lines, whichcarryWakesnow calls for the same two marks, and seven lines inholdRequest), without which a retake or a private turn's continuation that a hold kept as one with a merged start lost its mark once the batch was expanded or put back. Guessing at the expansion which marks a dedup added would lose a mark one request already held, and give a withdrawn request's marks to the others. Without them, a newer wake could evict a private one with the same reason. A hold could also keep only one of a private and a channel message that one source sent in one millisecond.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 at the same dispatch when its start turn is private (workTurnOf, one condition), since a script runs apart from its turn, whether it calls the tool by name or throughtoolsets--act; the bridge passes the start turn's privacy to its round's count, one argument, without which a refused call counted as a send for the round watching the script. A host'sexecuteToolCallcarrying a private turn's id is refused by four lines in its channel route, without which it posted to the fallback. The refusal counts achannelIdas naming a channel only when it's a non-empty string, wherepublishInputWithTurnLocuskeeps any string as given, an empty one included: by truthiness,channelId: 1passed the refusal and was replaced by the fallback. Its hint nameschannel_listbymayCallTool, the rule dispatch applies. The script bridge has no copy of the refusal of its own, since dispatch refuses the same calls.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 the tool list of every agent offeredchannel_publish, 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.Tests
Runs, on Linux (x86_64) with Node 24.22.2 and npm 11.19.1, after
npm install, which gave @animalabs/chronicle 0.5.1, @animalabs/context-manager 0.24.2, @animalabs/mcpl-core 0.4.0, @animalabs/membrane 0.8.0, @modelcontextprotocol/client and @modelcontextprotocol/server 2.4.0, discord.js 14.28.1, tsx 4.23.15, typescript 7.0.2 and ws 8.22.1:f055cb28d7:tsc --noEmitreports no errors, the build succeeds, andnpm test's runner, with the TAP reporter, runs 9,901 tests, of which 9,839 pass and 62 are skipped, with none failing.074ac60be0with this head'stest/all-except-e2e.test.ts, with the same Node and npm, 14 of the file's 35 tests fail. The two incident tests, "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", fail with the answer published inworkand innoisy. Seven others are the tests that a private turn "tells a server no channel": there the base tells the server the latest inbound message,{ channelId: 'work', messageId: 'w1' }, as the one the turn answers, and for a conversation fork's private turn, its home channel.What each requirement asserts, and the tests that pin it. 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:554;test/explicit-prose-routing.test.ts:129and:2322), 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 channel exists", 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 private-surface turn asks the registry for no locus, though it has a channel to give, and keeps its prose private", "a private-surface turn asks the registry for no locus, though it would give one as the resident's own, and keeps its prose private", "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_publishthat names no channel (no non-empty stringchannelId) is refused: the model's by its own turn, as a refusal (refused), a script's by its start turn, whether it calls the tool by name or throughtoolsets--act, and a host'sexecuteToolCallthat carries a private turn's id; 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 private turn's channel_publish whose channelId is a number, not a string, is refused as one naming no channel is, and reaches no channel", beside one "naming its channel by id", which posts there, "a private turn's channel_publish refusal names no channel_list to an agent refused it at dispatch", "a script's channel_publish that names no channel is refused in a private turn, and posts nothing, and its round counts it as no send", beside a channel turn's, whose round counts it as a send, "a script's refusal in a private turn points to channel_list only for an agent that may call it, not one refused it at dispatch", "a script a private turn started, left running past that turn, is refused a channel_publish through toolsets--act that names no channel while a channel turn's wait watches it, and that turn's prose still goes out", "a script a channel turn started, left running past that turn, publishes a channel_publish that names no channel while a private turn's wait watches it, judged by its start turn", inpublish-default-locus"a host's call carrying an open private turn's id meets the private turn's rule: refused, though a focus holds another channel", the same naming no agent and through a module's ctx.callTool, beside one naming its channel, which posts there, "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", "a conversation fork's private turn that enters a focus is told its plain speech stays private, not that it stays in its home", "a private turn tells a server no channel it speaks in and no message it answers", in locus, hybrid and locked mode under either locus rule, and "a conversation fork's private turn tells a server no channel, its home included, and no message it answers".locked-prose-routing, "a private turn isn't locked: its words reach no channel, it shows no typing, no notice names the lock for it, and channel_publish needs a channel", "in a private turn, prose_locus says the turn is private and where a turn that holds none speaks, and a move applies from the next such turn", "a locked turn made private mid-way is no longer locked: prose_locus says it is private, a move waits for a turn that holds none, and nothing calls its words drafts", "channel_open in a private turn moves nothing, and says the turn's plain speech stays private", "a closed-channel invitation made during a private turn defers to the latest word on where speech lands, naming no lock", and "plain speech goes to the lock, not the channel the turn came from, and the switch is primed once", whose primer now says "on every turn that holds no private input"; inturn-trigger-provenance, "a locked agent's private start that takes in a room's message names the room before the reply, and shows typing nowhere" and "a locked agent's private turn whose boundary is over budget names an addressed message it took in, for the restart to read"; and inall-except-e2e, "a locked agent's private turn that enters a focus is told its plain speech stays private, and that a turn that holds none speaks at the lock".inference:tokensandinference:content_blocktraces name no channel, in every mode. "a room's turn made private at a tool boundary stops its typing there and shows none after, and its private round's token and block traces name no channel", in locus, hybrid, explicit and locked mode, each beside its control with no private message, whose later round's traces name its channel; "a boundary that injects a private surface's message makes the room's turn private from there…", "under 'latest-inbound', an ADDRESSED mid-turn injection moves no private turn's locus, where it moves a channel turn's (above)", and "a room's turn whose boundary injects what a private turn's work brought back is private from there".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, as its own view keeps it, holds a private surface's message or a result or mail from a private turn's work as it starts, on the branch the context is on then, however the context came to end there, or when, as any start can, it takes a note that private input waits. A script's report doesn't count for a retake, whatever turn it names. With none, it speaks as on 0.30.0. "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…"; and for a row the view leaves out, "a private-marked row that only its own resident's view keeps leaves another resident's retake, a nudge after its channel turn, speaking as on main", beside "a private-marked row another resident's turn read makes that resident's retake, a nudge after it, speak in no channel".0the restore gives it, which a retake doesn't count. "a private turn's script report deferred across an update's restart, naming an earlier run's turn id above the next run's floor, keeps the next run's start out of every channel, and leaves a later turn its channel", and the same with the report's own id as a control; "a channel turn's script report deferred across an update's restart leaves the next run's start speaking in its channel, and a nudge after it as on main"; "a channel turn's script report deferred across an update's restart, where its turn turned private after it was queued, and the host crashed, keeps the next run's start out of every channel", and the same "where its queue entry doesn't say whether its turn was private, as damage or a hand edit can leave one"; and "a private turn's script report deferred across an update's restart, landed by the next run and read by no start before another restart, keeps the third run's first start out of every channel", beside a channel turn's, which "leaves the third run's first start speaking in its channel"./puppetpair, a reply that didn't read it, or a silent heartbeat tick's row after it isn't its answer; a channel turn's report isn't private input; a store from before this release, or a note one such a release left stale, is read from its rows; and a retake reads no run's turn ids. For each of a private surface's message, a private turn's work's mail, and a private surface's message stored under the resident's own name: "… stored before a restart, which woke nothing and no start read, keeps the next run's first start out of every channel"; "a private surface's message a silent heartbeat tick read before a restart keeps the next run's first start out of every channel"; "a private surface's message stored before a restart, with a /puppet pair stored after it, keeps the next run's first start out of every channel"; "private input stored for another resident while a resident's channel turn runs, which that turn doesn't read, keeps the resident's first start after a restart out of every channel"; "a private surface's message stored after the host moves its branch back past an earlier unread one keeps the next run's first start out of every channel"; "a private surface's message a start answered, after the host moves its branch back to before that answer and a silent heartbeat tick runs there, keeps the next run's first start out of every channel", and the same where the host moves it "during a silent heartbeat tick"; "a store from before this release, holding a private surface's message no start read, keeps the next run's first start out of every channel, across a further restart with a /puppet pair stored between"; "a store from before this release, holding a private surface's message no start read with a /puppet pair stored after it, keeps its first opening's first start out of every channel"; "a private surface's message a release that keeps no note stored after this release's note said nothing waits keeps the next run's first start out of every channel"; "wherever a crash cuts the store's log, from before a private surface's message is stored to after the start that read it answered, the next run's first start speaks in no channel while the message is stored unanswered, and in its channel once the answer and its record are", which opens a copy of the store's files as they stood after each record the run appended; "a channel turn's script report stored before a restart, which no start read, leaves the next run's first start speaking in its channel, and a nudge after it as on main"; "a private-marked row that only its own resident's view keeps, stored before a restart, leaves another resident's first start in the next run speaking in its channel"; and "a channel turn's script report the agent answered, in a store a second framework opens in the same process, leaves that framework's nudges as on main".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 from a room the turn has engaged 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.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'smainatfab6ab93c6.'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), except a failed run's for a live primary, which it stores and wakes itself (:2529, and What this leaves), and starts a spawn withcreateEphemeralAgentandspawnedBy(:3205–3210).aidein the tests)startStreamWithInjectionsand runs once the compile returns. No run stores one partway through a compile's own awaits; the count the start reads covers that window by the same comparison.68548adf(src/modules/tts-relay-module.ts:248–252there).