Repository navigation
feat(mcpl): journal channel and push event coalescing - #196
antra-tess wants to merge 4 commits into
Conversation
Implement the RFC-006 pushEvents/deferred profile with cancellation across mode changes, bounded rendering, grant rechecks, durable fallback recovery, and audit-only late results. Keep channel coalescing unadvertised. Add state-machine, gate, and WebSocket integration coverage. Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
|
| const push = { ...structuredClone(original), recovered: true }; | ||
| // Recovery appends fallbacks conservatively, without re-rendering or | ||
| // pretending we know whether a previous model consumed this subject. | ||
| const key = JSON.stringify(['recovered', push.serverId, push.epoch, push.params.eventId, push.audience]); |
There was a problem hiding this comment.
Recovered pushes cannot be replaced. After a restart, a pending fallback is stored under an event-ID key, but new pushes and retractions look up a key based on the feature set and coalescing key. If the producer updates or retracts that subject before assembly, the old fallback remains separate and is delivered alongside the update—or despite the retraction. The connection epoch also changes on restart, so matching only the old epoch would not resolve this.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/push-coalescer.ts
Line: 97
Comment:
**Recovered pushes cannot be replaced.** After a restart, a pending fallback is stored under an event-ID key, but new pushes and retractions look up a key based on the feature set and coalescing key. If the producer updates or retracts that subject before assembly, the old fallback remains separate and is delivered alongside the update—or despite the retraction. The connection epoch also changes on restart, so matching only the old epoch would not resolve this.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| // a SIGKILL preserves them. Permanent removal is owned solely by | ||
| // disconnectMcplServer, which deletes the trees explicitly. | ||
| connection.on('close', (code?: number | null, signal?: string | null) => { | ||
| this.pushCoalescer?.disconnect(connection.id); |
There was a problem hiding this comment.
Transient disconnects erase pending pushes. A temporary transport drop fires this close handler, which clears the coalescer's acknowledged pending pushes and saves the emptied snapshot. Reconnection registers the server again but does not restore those pushes. Unless the producer resends an event it was already told was accepted, its unread content is permanently lost.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 13253
Comment:
**Transient disconnects erase pending pushes.** A temporary transport drop fires this close handler, which clears the coalescer's acknowledged pending pushes and saves the emptied snapshot. Reconnection registers the server again but does not restore those pushes. Unless the producer resends an event it was already told was accepted, its unread content is permanently lost.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| // preserved across the transient close are resumed by idempotent | ||
| // registration. Then refresh tools (server may have different tools). | ||
| connection.on('reconnect', (info?: { attempts?: number }) => { | ||
| this.coalescingEpochs.set(connection.id, randomUUID()); |
There was a problem hiding this comment.
Reconnect leaves recovered pushes asleep. If the host restarts while a producer is offline, it retains the pending fallback but cannot wake it until the server is connected and authorized. This reconnect handler re-registers the server without calling
wakeRecovered(), so an otherwise idle host can leave that fallback undelivered indefinitely.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 13135
Comment:
**Reconnect leaves recovered pushes asleep.** If the host restarts while a producer is offline, it retains the pending fallback but cannot wake it until the server is connected and authorized. This reconnect handler re-registers the server without calling `wakeRecovered()`, so an otherwise idle host can leave that fallback undelivered indefinitely.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| if (this.coalescingSharedReaders?.has(agent)) { | ||
| this.ensureCoalescingBranch(); | ||
| const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined; | ||
| if (primary) await this.pushCoalescer?.assemble(this.coalescingAudience(), push => { | ||
| this.materializeCoalescedPush(push, primary); |
There was a problem hiding this comment.
Assembly bypasses turn deferral. Any resident agent can assemble a push, but publication writes directly into the primary agent's context. If another resident activates while the primary is mid-turn, this bypasses the primary's normal turn-alive deferral: its stored history gains content that its live tool continuation never saw, breaking their ordering.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8459-8463
Comment:
**Assembly bypasses turn deferral.** Any resident agent can assemble a push, but publication writes directly into the primary agent's context. If another resident activates while the primary is mid-turn, this bypasses the primary's normal turn-alive deferral: its stored history gains content that its live tool continuation never saw, breaking their ordering.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const requests: InferenceRequest[] = [...this.agents.values()] | ||
| .filter(agent => this.coalescingSharedReaders.has(agent) && agent.name !== this.subconsciousAgentName) | ||
| .map(agent => ({ agentName: agent.name, reason: 'mcpl:push-event', source: push.serverId, timestamp: Date.now() })); |
There was a problem hiding this comment.
Coalesced pushes lose channel routing. When a feature-set push carries channel provenance, these wake requests omit the derived channel ID and addressed flag. Unlike ordinary pushes, materialization also does not qualify or register the channel. For a closed-channel DM forwarded through
push/event, the reply can go to the global fallback or have no routable DM channel.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 7284-7286
Comment:
**Coalesced pushes lose channel routing.** When a feature-set push carries channel provenance, these wake requests omit the derived channel ID and addressed flag. Unlike ordinary pushes, materialization also does not qualify or register the channel. For a closed-channel DM forwarded through `push/event`, the reply can go to the global fallback or have no routable DM channel.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| this.receipts.set(dedup, result); | ||
| if (this.receipts.size > 4096) this.receipts.delete(this.receipts.keys().next().value!); | ||
| this.save(); | ||
| if (s.slot && !s.wakeQueued) s.wakeQueued = this.options.wake(s.slot.push, key); |
There was a problem hiding this comment.
Replacement retains obsolete gate wake. If an immediate wake is already queued, replacing its push does not recheck whether the new content now matches a
skip rule. Changing a debounced push to skip likewise leaves its old timer armed. In either case, an obsolete wake can start a turn and materialize content that the current gate policy would not have woken for.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/push-coalescer.ts
Line: 182
Comment:
**Replacement retains obsolete gate wake.** If an immediate wake is already queued, replacing its push does not recheck whether the new content now matches a `skip` rule. Changing a debounced push to `skip` likewise leaves its old timer armed. In either case, an obsolete wake can start a turn and materialize content that the current gate policy would not have woken for.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| existing.events.push(event); | ||
| // Unrelated traffic must not postpone a coalesced wake indefinitely either. | ||
| if (existing.events.some(e => e.coalescingSubject)) return; | ||
| clearTimeout(existing.timer); | ||
| existing.timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs); |
There was a problem hiding this comment.
Unrelated events wake too early. When a normal event joins a debounce batch containing a coalesced push, this return keeps the push's existing deadline for both. A normal event arriving just before that deadline therefore triggers inference early, rather than after its configured quiet period.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1471-1475
Comment:
**Unrelated events wake too early.** When a normal event joins a debounce batch containing a coalesced push, this return keeps the push's existing deadline for both. A normal event arriving just before that deadline therefore triggers inference early, rather than after its configured quiet period.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const dedup = JSON.stringify([push.serverId, push.epoch, params.eventId]); | ||
| const receipt = this.receipts.get(dedup); | ||
| if (receipt) return structuredClone(receipt); |
There was a problem hiding this comment.
Retries can duplicate delivered events. Receipts are keyed to a connection epoch and kept only in a bounded memory map. If an acceptance receipt is lost and the producer retries the same
eventId after reconnect, the new epoch treats it as a fresh occurrence and can append already consumed content again. The same can happen within one connection after 4,096 later receipts evict the original.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/push-coalescer.ts
Line: 130-132
Comment:
**Retries can duplicate delivered events.** Receipts are keyed to a connection epoch and kept only in a bounded memory map. If an acceptance receipt is lost and the producer retries the same `eventId` after reconnect, the new epoch treats it as a fresh occurrence and can append already consumed content again. The same can happen within one connection after 4,096 later receipts evict the original.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| cancelWake: subject => { | ||
| this.eventGate?.cancelCoalesced(subject); | ||
| const requests = this.coalescingWakes.get(subject); | ||
| if (requests) this.pendingRequests = this.pendingRequests.filter(r => !requests.includes(r)); | ||
| this.coalescingWakes.delete(subject); |
There was a problem hiding this comment.
Retraction leaves queued gate wake. Once a coalesced debounce timer fires, its inference request has left the gate timer and buffer, and it is not tracked in
coalescingWakes. A retraction before assembly cannot cancel that request, so the host may spend a model turn on work withdrawn before it was shown. This is a non-blocking source of unnecessary inference.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 7291-7295
Comment:
**Retraction leaves queued gate wake.** Once a coalesced debounce timer fires, its inference request has left the gate timer and buffer, and it is not tracked in `coalescingWakes`. A retraction before assembly cannot cancel that request, so the host may spend a model turn on work withdrawn before it was shown. This is a non-blocking source of unnecessary inference.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Share subject identity across channels/incoming and channel-scoped pushes. Preserve stable platform reply targets and conversation routing, enforce current grants and authoritative registrations on both lanes, and keep transport epochs separate from configured binding identity. Preserve pending work and receipts across reconnects, persist idempotency receipts beyond the memory window, serialize shared-context assembly safely, and cancel obsolete wakes without changing ordinary debounce. Add mixed-lane, authorization, recovery, routing, and review-regression coverage. Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
…on migration Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Persist decisions before effects, replay pending state and receipt indexes, and recover context publication with a durable idempotent outbox. Add crash-boundary, pure replay, Chronicle reopen, and framework restart coverage. Validation: build passed; full suite 1053 pass / 1 existing fail / 4 skipped versus main 967 pass / 1 fail / 4 skipped. Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
slimepriestess
left a comment
There was a problem hiding this comment.
Review at exact head a6e03f6, protocol read against MCPL#5 rev 6 as asked. APPROVE. Verified here: suite with your exact flags 1039/1044 (1 fail, 4 skipped), and I re-ran the baseline first-hand on a clean 5498805 worktree — the same single conversation-routing "Queue is closed" post-test rejection fails identically there, so the failure set is unchanged and all 72 coalescing tests pass.
The core of the machine held up under targeted reading and probing: subject identity (binding + explicit scope + key, never origin-inferred); the double authorizeCoalesced around the routing await; the receipt/dedup index bridging interrupted writes via the snapshot's latest receipt; restore-without-replaying-renders; the retract/replace/displace outcomes including noted-with-notice; mixed-lane reply identity via the stored previous identity; audience-branch filtering on restore; accept-during-assemble races (the selected snapshot re-reads the live slot, a replace cancels the render, a late render is audit-only); the occurrence filter dropping stale wakes while the boundary requeue — carrying no subject — is never filtered; conversation bind-predicate parity (metadata.mentioned on both paths, pinned by your test); disconnect-during-render collapsing a deferred batch to its self-contained fallback without notices, with the cancelled render's finally unable to clobber the rebuilt slot; and the recovered-delivery dedup honoring both the binding-keyed and the legacy epoch-keyed delivery ids. The fixed per-subject debounce deadline and the no-[Gate:]-trace choice for withdrawn work are both right.
Scheduling note — one README sentence wanted, not a blocker. The assembly-owner gate is assembly-scoped (released at the stream handoff, beginAgentTurn's return true), so shared readers' streams still overlap; what changes is that (a) shared readers can't overlap during context assembly itself, which mainly re-queues the async side entries onto the ~10 ms poll, and (b) while coalesced content is pending, a shared reader defers its whole turn until no other shared reader is mid-turn — a real, traffic-tied wait that looks intended ("shared-context publication waits for other live readers"). The README paragraph describes the publication rule but not the turn-deferral; one sentence there ("while coalesced content is pending for the shared context, a shared-context resident's turn waits for other residents' live turns to finish") would keep the next reader from rediscovering it in the scheduler.
Cross-PR seam with #195 (focus), for whichever lands second. Coalesced admission never passes through handleMcplChannelIncoming/handleMcplPushEvent, so FocusCoordinator.onIncoming never sees these messages: no focusHeld stamp, no autoreply, and materializeCoalescedPush writes to the context manager at any turn's assembly regardless of focus state. Composed as-is, a coalesced message from a held channel enters the resident's view mid-focus (the wake side is partially covered via the gate's trigger filter; materialization is the leak). Flagging it identically on both PRs so the second rebase owns it — the focus hold needs a seam at admission/materialization, or focus refuses while pending subjects exist. The two branches also edit adjacent regions of framework.ts and event-gate.ts, so the rebase conflict is real wherever the merge order falls.
Compatibility, half-verified for you. I read our discord-mcpl against the new registration requirement: it registers via channels/register + channels/changed (registerAndNotifyNew, idempotent) and sends channels/incoming only for open channels, so the breaking change should not bite it. Portal I could not check today (relay's down on my side) — worth the same one-look before rollout, since ensureChannelRegistered placeholders no longer admit inbound and the rejection is per-message and quiet.
Small notes, no action needed: gate policies see the registered/composite channel id for coalesced pushes but the bare origin.channelId for ordinary pushes — a pre-existing inconsistency this PR makes more visible; the inference/request-during-render refusal is per-server, so a second server can still request inference during another's render (seems fine, worth knowing); the in-memory receipts map evicts FIFO at 4096 while the durable tree grows append-forever (fine for an idempotency index); the orphaned-render audit path with its 4096 cap is good hygiene.
(Adversarially second-read by the Opus session per our usual split — including a catch that corrected my own first read of the owner gate's scope before this posted.)
|
Review of The findings from the previous head are unchanged
Finding 1 comes from the Two new problems from the journalPer-message cost. 300 channel messages with 200-character bodies, same fixture, same gate, drained every 100 so the cap is not hit:
One storage fault halts all coalesced traffic until restart. I made SuggestionThe journal is a heavier mechanism than the guarantees need. Revision 7 deliberately asks only for outcomes — nothing accepted is silently lost, a retry inside the window is not a second occurrence, a changing subject is still heard — and permits a host to flush pending content as ordinary events on any interruption. That allows an implementation with no write-ahead log on the ingest path at all: keep pending content in memory, sync it at the same points the framework already syncs, and on any doubt (disconnect, restart, capacity, fault) deliver it as plain messages and mark history Probe sources available on request; they are ~120 lines on top of the existing integration fixture. |
Greptile's review comment on anima-research#254 (r4212910374): the new `while (wakes.length < 3)` loop has no overall bound. If recovery keeps re-waking the batch but the `recovery-wake` trace is lost or renamed, each `eventually` succeeds on the pending request while `wakes` never grows, so the test loops forever instead of reaching a failed assertion, and npm test sets no test timeout. Confirmed at 3d9f301: with the audit kind renamed in the compiled push-coalescer, R17 alone was still running when an external 25 s limit killed it. Two bounds, one for each way the test can fail to finish: - A per-round progress assertion. After a failed freeze the batch is kept with its wake withdrawn, and the only thing that re-wakes it is the recovery timer. The timer calls host.audit (a synchronous emitTrace to the trace listeners) before `void wakeForBatch`, and wakeForCoalescedBatch pushes to pendingRequests only after an await. Restore-time waking runs only at startup, and R17 sends no producer retry. So in passing code every round adds at least one traced wake, and asserting `wakes.length > before` bounds the loop at three rounds. The assertion asks for strictly positive progress, not one wake per round: a drain may still carry several attempts, which is what 3d9f301 fixed. - A 30 s per-test timeout on R17. No assertion inside the test can end a drain that never returns: a backoff regressed to zero delay keeps runUntilIdle in a tight loop of model turns. Only the runner can end that. R17 ran in 310-343 ms in CI (run 37699369398, all four platforms) and about 1 s here, so 30 s leaves wide margin. No other test in the repo sets a node:test timeout. This one does because it is the backoff test, the one such a regression reaches first, and neither npm test nor ci.yml (no timeout-minutes) bounds it otherwise. Evidence: Node 24.19.0, released dependencies (chronicle 0.4.0, context-manager 0.13.0, membrane 0.5.86), load about 6 to 9. R17 alone, run as npm test runs it (node --test --test-force-exit, no --test-timeout) with --test-name-pattern='^R17'. Each mutation was applied to the compiled push-coalescer and restored afterwards. - unmutated: 10 of 10 pass, 0.96-1.13 s each. - recovery-wake kind renamed: fails "each re-wake is a traced recovery wake" in 0.55 s. At 3d9f301 it was still running at the 25 s kill. - zero delay: "test timed out after 30000ms", after about 400 model turns. The after hook ran f.close (framework.stop logged its final sync's EIO from the still-failing store), the fixture directory was removed, and the process exited 1 on its own at 30.7 s. - delay without doubling, and a tripling delay: each fails "and the delay doubles". - attempt fixed at 1: fails "consecutive failures count up". - a 65 ms sleep before each processNextEvent, so recovery fires inside a drain: 5 of 5 pass, two of them with attempts [1, 2] in one round. - the whole mcpl-coalescing file: 65 pass / 1 fail of 66. The failure is anima-research#196 finding 2 / vector 34c's wall-clock bound (300 messages took 40285 ms), the separate timing case anima-research#254 already leaves out of scope. tsc --noEmit is clean. Production code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vector 34c sends 300 coalesced channel messages one at a time and failed when they took 30 s or more. Each acceptance waits for its durable commit, a store sync, so the time follows the disk and the host's load more than the code. In six runs on a Linux host at load averages of 57 to 78, three of them beside six busy node loops, main's version took from 10.8 s to 34.1 s and failed once. With each store sync slowed by 120 ms, it failed both runs on time alone, at 44.2 s and 46.7 s, since 300 syncs at 120 ms take 36 s by themselves. The bound guarded the cost #196's review measured, 85 ms a message, from a journal that synced the store for each of three operations per message. The test now counts store.sync() calls during the 300 sends and requires at most 300, so a commit shared between acceptances would still pass. It turns the framework's periodic store sync off (syncIntervalMs: 0), which the fixture never starts anyway, so every sync it counts is an acceptance's. It reports the time beside the count, and its functional checks are unchanged. This version passes with each store sync slowed by 120 ms and beside six busy loops, making 300 store syncs in every run. With each acceptance committing twice, it fails with 600 store syncs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…acts Review findings on 3f7aafe from Hugo-1004 (room-220 #41587) and a request from Oscar-1005 (#41589). Freeze at admission. An ordinary (uncoalesced) channels/incoming message or push/event used to get only `acceptedAt` at admission; its envelope was built later, in handleMcplChannelIncoming/handleMcplPushEvent, from the registry's CURRENT label and endpoint binding, and the acceptance was observed there. An item accepted (and acknowledged) before a rename or rebind could therefore acquire the new label or binding while it waited in the queue, and a receipt clock read stale until the queue drained. ChannelRegistry.handleIncoming and PushHandler now call a framework-supplied `acceptInbound` at admission, before queueing and before the acknowledgement: the framework builds the envelope and observes the acceptance there, and the event carries the frozen envelope to its handler, as coalesced work already did. The handlers only fall back to building (and observing) an envelope for a direct caller that bypassed admission. Lane facts. Channel and unscoped envelopes now carry `lane` ('channels/incoming' | 'push/event') and `coalesced: true` for RFC-006 admission. What an eventId is worth depends on the admitting lane's contract (push/event dedups by eventId; coalesced admission guarantees stable retries and distinct versions; an uncoalesced channels/incoming eventId is adapter-supplied with no guarantee), and its mere presence cannot say which. Facts only: the receipt policy that uses them stays with the receipt lane. Strict reader. readInboundSource validates every field it returns: identity strings non-empty, acceptedAt finite, deferred/materialized/coalesced exactly true when present, a known lane, and refuses arrays. Tests: both ordinary lanes hold an admitted item between admission and processing (by intercepting the framework's queue), assert the observer ran at admission with the original label, rename the channel, release, and assert the stored envelope equals the admission envelope; lane/coalesced facts asserted on ordinary and coalesced items; the reader refuses each malformed field. Suite (machine load average ~40 during the run): all files except code-execution 1274/1275 with code-execution separately 36/36. The one failure is mcpl-coalescing's wall-clock bound (#196 vector 34c, 300 fsynced coalesced acceptances < 30 s): 40.0 s here, and 40.4 s for the same test on an untouched 64c480b build under the same load, so it is load, not this change (it passed at 24.1 s on 3f7aafe at lower load). The full-suite PyRunner "rejects concurrent execs" test hung twice under this load in the combined run and passes alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ChannelRegistry.publish(conversationId, text, {serverId?, channelId}) is the
single delivery executor behind plain speech (routeSpeech now uses it), and
will be behind resident resends of held drafts. It returns a PublishOutcome:
- `delivered`: the connector returned `delivered: true` (with its messageId).
A historical receipt, not a claim the message still exists.
- `failed`: provably nothing posted. Refused before dispatch (unknown channel
id, a channel id registered by more than one server without the server
named, no channels.publish grant, a failed open of a closed destination, a
request refused unsent by a closed connection: McplRequestError not-sent),
or the connector answered `delivered: false` naming no posted message.
- `unknown`: dispatched, and no valid confirmation came back: a JSON-RPC
error response (which can follow a partial multi-part post, discord-mcpl
#35), a timeout, a lost connection, `delivered: false` that names a posted
message, or a missing/malformed receipt (#163's rule: a missing receipt is
no longer counted as delivered). The connector's words are kept verbatim in
`reason` and its error `data` in `detail`.
Every outcome names the destination the registry resolved (serverId,
channelId, label at attempt time), never the caller's spelling. A channel id
shared by two servers is refused instead of routed via whichever registered
first.
The contract was agreed with Agnes-984 (only `delivered === true` confirms;
pre-dispatch refusals and `delivered:false` mean failed; anything after
dispatch is unknown) and extended per Tessa (#41665, #41679): a partial send
is unknown, never failed, and its posted/in-flight facts stay with the
attempted destination.
routeSpeech keeps its signature and traces; its failure path now carries
`outcome: 'failed' | 'unknown'` to the trace (`mcpl:speech-route-failed`) and
to onRouteFailure, and the resident's `[discord-send-failed]` marker says
which: "could not be delivered ... Nothing was posted" versus "was not
confirmed ... may or may not have been posted; check the channel before
sending it again". The marker metadata carries `outcome`.
Tests: test/publish-outcome.test.ts (scripted server per case: delivered,
delivered:false with and without a posted id, missing/empty/malformed receipt,
error response with data, timeout, not-sent, unknown channel, a shared channel
id with and without the server named, missing grant, routeSpeech's two
outcomes; and an end-to-end run over the speech-route fixture asserting the
two marker wordings). 43/43 with the routing, request-outcome and
route-failure-targeting files.
Suite (--test-concurrency=6, load average ~20-40): all files but
code-execution 1301/1303, code-execution 36/36. The two failures are
mcpl-coalescing timing tests that fail identically on an untouched 64c480b
build in this environment: #196 vector 34c's wall-clock bound, and R17's
40 ms storage-outage backoff, which counted a fourth recovery wake; running
the whole coalescing file on 64c480b reproduced the same R17 failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shelf-351 (room-203). Suppressed plain speech used to survive only as a count
in the turn receipt ("N segment(s) suppressed ... resend with a send tool"),
and explicit mode's bounced text lived in one in-memory latest-wins
clipboard: the suppression rule was right, the penalty was amnesia (a
Connectome resident's report). Now the exact words are kept as drafts until
the resident resends or dismisses them.
What becomes a draft (src/prose-drafts.ts, reasons):
- explicit-send: prose silenced by an explicit send in the same round or a
sticky earlier one (channel_publish, send_message, reply_message, send_dm,
say, whisper, and now a drafts resend), in locus and hybrid modes, mid-turn,
trailing and text-only (a guard retry carrying the silence), native and XML
tool modes. The turn tracks WHY it is silenced (silenceCause): an explicit
send holds prose as drafts; skip_reply and silent turns keep it private.
Hybrid prose is split into envelopes and a draft holds an envelope's body,
not its routing syntax, with "written for >>>target" as a note.
- no-destination: hybrid prose after a failed envelope (fail-closed until a
new valid target), formerly counted as suppressed.
- bounced: explicit/hybrid bounces. `{{unsent}}` keeps meaning the latest
bounce while that draft is open; delivering through it journals an attempt
on the draft; `>>skip_reply {{unsent}}` dismisses it; an unconfirmed latest
bounce is never substituted silently. Re-bouncing `{{unsent}}` alone keeps
the one draft.
Never drafted (counted in the receipt): skip_reply, a same-round private
think, silent turns, proseRouting=disabled narration.
Storage: a RecordJournal (typed Chronicle records that do not follow branch
switches), agreed in room-220 instead of the JSONL file first proposed to
Tessa/Ada/June; the contract is the same. Entries held / attempt / outcome /
dismissed / noticed, every one durable before its call returns; an attempt is
journaled BEFORE its request leaves AF, so an attempt without an outcome reads
as unconfirmed, never unsent. Undo, rollback, checkout and restarts never
change drafts; nothing is ever sent from them on its own. A failed journal
write reconciles (load) and reports the words it could not hold.
Notices: a private notice naming new drafts at the next tool boundary when
the live stream presents injections (native rounds; Membrane's
supportsInjectedMessages when it ships, room-220 #41701/#41870); otherwise
the turn-end [delivered] receipt names them ("held as drafts d-…"); drafts no
notice reached (crash, aborted turn) are named at the next fresh turn's
start. Notices name their resident: residents of one framework share a
message slot (#197). The bounce notice now goes to the bouncing resident
(forAgent) instead of the primary.
The drafts tool (with MCPL channels): list | read | resend | dismiss, scoped
to the caller; nullable unused fields (save_recent_image's pattern). resend
publishes verbatim, in order, through ChannelRegistry.publish, stops at the
first non-delivery, counts as an explicit send, marks the channel engaged,
returns a delivered draft's historical receipt instead of resending, needs
confirmDuplicate for an unconfirmed one, refuses dismissed drafts and a draft
already in flight, and honours a conversation agent's home binding. Results
are plain text blocks, so drafts read exactly as written.
Registry: deliverSpeech (routeSpeech's PublishOutcome form; routeSpeech wraps
it unchanged) and resolveDestination (exact resolution, shared ids refused).
Text-only routing goes segment by segment when the content holds a membrane
tool_attempt (Greta's 376, room-220 #43515).
Tests: test/prose-drafts.test.ts, 18 (store: order/ids/isolation, reopen
with an outcome-less attempt, branch independence, journal failure,
latest-bounce; end to end over the speech-route fixture: three-segment
reply held/named/injected/read/resent verbatim, XML mode, skip_reply and
private think, disabled mode, resident scoping, failed then unknown then
confirmed resend, June's rollback and restart cases and dismissal across a
rollback, crash catch-up, explicit bounce across restart via {{unsent}},
resend silencing adjacent prose, parallel resend, refusals). Receipt
expectations updated in present-while-acting and explicit-prose-routing; the
explicit stub gains deliverSpeech/resolveDestination; the fixture answers
tools/call at once.
Suite (--test-concurrency=4, load ~20-30): 1315 pass, 1 fail (#196 vector
34c's wall-clock bound) and mcpl-coalescing.test.js cut off by the 900 s
guard; that file alone afterwards: 66/66. code-execution alone: 36/36.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck drafts
Tessa-974's review of 351 (room-203 #44135, #44214; probe
/tmp/tessa-974-draft-state-probe.mjs):
1. Uncertainty was cleared by a later failure: unknown, then a confirmed
retry that failed, read `held`, and a third resend went out without
confirmation. A draft is now `unconfirmed` while ANY attempt has an
unknown or unrecorded outcome and none was confirmed; uncertainAttempt()
names that attempt, and refusals, the list line and a failed retry's
receipt cite it (destination, time, reason) rather than "the last one".
2. The up-front batch check went stale across an await: a batch [A,B]
waiting on A let a concurrent resend of B go out, then published B again;
a dismissal of B meanwhile was ignored. A resend now claims every draft of
its batch before its first await (a concurrent resend or {{unsent}} of
any of them is refused while the batch owns it), and re-reads each draft
from the current projection at its own dispatch boundary, so a dismissal
or delivery since the batch began stops it there (also after a journal
reconcile replaced the projection).
3. The crash catch-up said "They were not sent" of an unconfirmed draft. It
and the list now render each draft's actual state: "held (not sent)",
"held (every attempt failed: nothing was posted)" or "UNCONFIRMED — an
attempt to … may have been posted".
4. bounceProse expanded `{{unsent}}` from an unconfirmed (or in-flight)
latest bounce into a fresh held draft when the envelope's target failed,
which then resent without confirmation. Such a draft is never copied: it
stays held as itself, only the resident's other words become the new
draft, and the bounce notice says so.
Privacy boundary, agreed with Tessa (#44155, #44214): drafts are never
published except by their resident's explicit resend (or {{unsent}}), only
that resident can list or act on them, and notices go into the resident's
own history, which residents sharing one message slot (#197) already share.
The tool description, notices, module doc, explicit-routing doc and
changelog now say that instead of "private".
Tests (prose-drafts.test.ts, 22): unknown then failed stays unconfirmed and
refuses an unconfirmed third resend; the catch-up renders held and
unconfirmed drafts truthfully; an at-risk bounce is not copied into a new
draft and {{unsent}} then means only the fresh words; and, through the real
drafts handler with publish completions under the test's control, a
concurrent resend of a queued draft is refused with B published once, and a
draft dismissed while the batch waits never reaches publish.
Suite (--test-concurrency=4, load ~7-12): 1329/1330 (#196 vector 34c's
wall-clock bound at 30.7 s) + code-execution 36/36. Composition with
Greta-1013's 376 companion (6ff476d, membrane#101 pack) merged cleanly:
30/30 incl. her xml-tool-call-notices and a composed text-only
tool_attempt split check (room-220 #44367).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
Unread edits and deletions need the same behavior across
channels/incomingandpush/event: replace or retract work until assembly, then preserve consumed history. Recovery must reconstruct those decisions consistently, including a crash between appending content to context and recording delivery completion.Changes
mcpl/coalescing-journalthe single authoritative append-only operation log. Acceptance, replacement, retraction, render decisions, recovery, and publication receipts are appended facts. One pure reducer builds pending subjects, consumed-history knowledge, publication intents, and receipt-position indexes in live operation and replay. Replay performs no RPCs, context writes, or wakes.allowedIncomingChannelspatterns on both channel lanes. Channel-scoped pushes also require push authorization. Outbound placeholders inferred fromorigincannot authorize inbound content.Startup currently replays the full journal and keeps occurrence-to-record positions in memory. Recovery time and receipt-index memory therefore grow with journal history; no separate authoritative snapshot or receipt database is maintained.
Tests
npm run build: passes.npm run pretestthennode --test --test-concurrency=4 dist/test/*.test.js: 1053 pass / 1 fail / 4 skipped. All 86 coalescing tests pass; the sole failure matches the upstream baseline.5498805baseline with the same command: 967 pass / 1 fail / 4 skipped. The existing failure isconversation-routing.test.js, which reports an asynchronousQueue is closedrejection after its final test.git diff --check: passes.The full comparison omits
--test-force-exit, which can stop reporting nested suites early and obscure the baseline cleanup failure. Wire integration uses a real loopback WebSocket and a mock model provider.Compatibility
Servers that relied on outbound placeholders inferred from push
originmust register channels throughchannels/register/channels/changed, or complete an authorizedchannels/openwith a valid descriptor, before sending inbound channel content. This also applies to ordinary incoming messages. The breaking changelog fragment documents the migration.Companion PRs
MCPL #5 now proposes revision 7 (
9a319e2). The authoritative journal architecture remains valid. Current implementation6352d47still targets revision 6 and needs these revision 7 alignment changes:coalesce.initial, including validation and establishing a fresh subject after lost history without resetting known consumed history.Durable receipts already exceed the default one-hour retry guarantee. Conservative cross-context consumption and tail placement remain valid choices. The RFC can land independently; this PR does not yet claim revision 7 conformance. Producers must opt into advertised support, and the proposal remains Draft.
Not verified
No live provider or production Discord, Portal, or editor deployment was exercised. No production rollout, producer migration, or long-history performance benchmark is included. Pending coalesced events enter context at activation assembly; live tool-continuation injection is outside this implementation.
mcpl-event-coalescing.added.mdandmcpl-channel-registration.breaking.md.AI-assisted implementation by Codex (GPT-6).