Skip to content

feat(mcpl): journal channel and push event coalescing - #196

Closed
antra-tess wants to merge 4 commits into
mainfrom
feat/mcpl-event-coalescing
Closed

antra-tess wants to merge 4 commits into
mainfrom
feat/mcpl-event-coalescing

Conversation

@antra-tess

@antra-tess antra-tess commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Unread edits and deletions need the same behavior across channels/incoming and push/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

  • Make mcpl/coalescing-journal the 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.
  • Persist and sync each operation before acknowledging admission or performing its effect. Assembly appends an intent that seals the occurrence against replacement, publishes with that intent's stable context delivery ID, then appends completion. An interrupted publication retries idempotently. Storage failures stop further mutations until a fresh replay resolves whether the append committed.
  • Share subjects by host-owned server binding, explicit scope, and coalescing key across both lanes. Keep platform reply identity and conversation routing through mixed-lane updates. Reconnect changes authority, not occurrence identity; endpoint/command reassignment creates a separate binding namespace.
  • Require current grants, authoritative channel registration, and optional allowedIncomingChannels patterns on both channel lanes. Channel-scoped pushes also require push authorization. Outbound placeholders inferred from origin cannot authorize inbound content.
  • Keep pending content outside context until safe activation assembly. Shared contexts wait for other live readers; conversation forks receive their own events. Deferred rendering has a five-second deadline, bounded notices/content, authority rechecks, and observation-only late results. Recovery uses admitted fallback content without repeating an unfinished render.
  • Re-evaluate wake eligibility on replacement and cancel obsolete immediate, debounced, and queued wakes. Coalesced subjects have independent deadlines so unrelated debounce behavior is preserved.
  • Import older experimental snapshot/receipt stores once into the journal. Because the old context and snapshot writes were independent, imported pending deliveries are conservatively sealed and deduplicated against legacy context markers. Subsequent recovery ignores the old stores.

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 pretest then node --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.
  • Upstream 5498805 baseline with the same command: 967 pass / 1 fail / 4 skipped. The existing failure is conversation-routing.test.js, which reports an asynchronous Queue is closed rejection after its final test.
  • Focused coalescer, journal, and WebSocket integration run: 81 pass / 0 fail.
  • New fault tests exercise failure before/after durable acceptance, prepared intent before publication, completion before/after append, unfinished-render recovery, and the actual framework restart after context publication without completion. Every live journal prefix reconstructs identically without replay effects. Real Chronicle compaction/reopen and one-time legacy import are covered.
  • Existing coverage preserves mixed-lane edits/deletions, stable reply targets, permission changes, channel registration, conversation routing, shared-context isolation, restart/reconnect, duplicate receipts, and render/wake races.
  • 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 origin must register channels through channels/register/channels/changed, or complete an authorized channels/open with 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 implementation 6352d47 still targets revision 6 and needs these revision 7 alignment changes:

  • Handle coalesce.initial, including validation and establishing a fresh subject after lost history without resetting known consumed history.
  • Accept empty channel-scoped withdrawal while retaining supplied deletion notices for consumed/unknown history.
  • Degrade to ordinary delivery when coalescing slots are exhausted; deferred overflow uses its admitted fallback.
  • Add regression coverage for channel subjects spanning different platform messages and their reply provenance.

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.

  • Changelog fragments: mcpl-event-coalescing.added.md and mcpl-channel-registration.breaking.md.

AI-assisted implementation by Codex (GPT-6).

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>
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 0/5 Tier: apex

[High risk] Adds push event coalescing with deferred rendering and persistence.

This PR is not ready to merge: admitted pushes can be lost or duplicated, and assembly and gate behavior can deliver them at the wrong time.

Findings

  1. P1 Recovered pushes cannot be replaced ▶
  2. P1 Transient disconnects erase pending pushes ▶
  3. P1 Reconnect leaves recovered pushes asleep ▶
  4. P1 Assembly bypasses turn deferral ▶
  5. P1 Coalesced pushes lose channel routing ▶
  6. P1 Replacement retains obsolete gate wake ▶
  7. P1 Unrelated events wake too early ▶
  8. P1 Retries can duplicate delivered events ▶
  9. P2 Retraction leaves queued gate wake ▶
Fix with agent prompt
### Issue 1
src/mcpl/push-coalescer.ts:97
**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.

### Issue 2
src/framework.ts:13253
**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.

### Issue 3
src/framework.ts:13135
**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.

### Issue 4
src/framework.ts:8459-8463
**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.

### Issue 5
src/framework.ts:7284-7286
**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.

### Issue 6
src/mcpl/push-coalescer.ts:182
**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.

### Issue 7
src/gate/event-gate.ts:1471-1475
**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.

### Issue 8
src/mcpl/push-coalescer.ts:130-132
**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.

### Issue 9
src/framework.ts:7291-7295
**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.

Summary

The PR adds feature-set push coalescing, deferred rendering at activation assembly, gate integration, and durable fallback recovery.

  • The new paths need correction for recovery across reconnect/restart, retry identity, multi-agent assembly, and gate wake transitions before the advertised behavior is reliable.
  • Channel-origin routing and mixed debounce batches also diverge from existing behavior.

Reviews (1) · Last reviewed commit: "feat(mcpl): coalesce unread feature-set ..."

Comment thread src/mcpl/push-coalescer.ts Outdated
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/framework.ts
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/framework.ts
// 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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/framework.ts Outdated
Comment on lines +8459 to +8463
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/framework.ts Outdated
Comment on lines +7284 to +7286
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() }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/mcpl/push-coalescer.ts Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/gate/event-gate.ts Outdated
Comment on lines 1471 to 1475
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/mcpl/push-coalescer.ts Outdated
Comment on lines +130 to +132
const dedup = JSON.stringify([push.serverId, push.epoch, params.eventId]);
const receipt = this.receipts.get(dedup);
if (receipt) return structuredClone(receipt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/framework.ts Outdated
Comment on lines +7291 to +7295
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

antra-tess and others added 2 commits September 30, 2026 08:31
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>
@antra-tess antra-tess changed the title feat(mcpl): coalesce unread feature-set pushes and render deferred updates feat(mcpl): unify channel and push event coalescing Sep 30, 2026
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>
@antra-tess antra-tess changed the title feat(mcpl): unify channel and push event coalescing feat(mcpl): journal channel and push event coalescing Sep 30, 2026

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

@antra-tess

Copy link
Copy Markdown
Collaborator Author

Review of 6352d47 (journal rework), by running probes against the head in a scratch clone (npm run build, then node --test on added test files that reuse the PR's own WebSocket fixture). CI is green and the body is candid that revision 7 is not yet implemented; this is about what the head does today.

The findings from the previous head are unchanged

# Probe a6e03f6 6352d47
1 Transport drop + reconnect, server does not re-register; ordinary channels/incoming (no coalesce) rejected unknown channel rejected (on main it is accepted)
2 128 unread ambient coalesced messages, then a chat:mention 129th refused host pending-subject limit reached refused
3 Create → retract of a never-read message, after restart appended / noted, notice in context fixed
3b Same, after 130 consumed subjects (eviction) — appended / noted (forgotten sets uncertain permanently)
4 Pending content, then a turn started with a context_budget_restart trigger assembled mid-logical-turn assembled
5 Coalesced input consumed by a turn, then undoLastTurn input rolled back with the turn; receipt blocks a retry same (an ordinary push survives the same undo)

Finding 1 comes from the close handler calling removeServer, is a regression for hosts that never use coalescing, and is not in the Compatibility section. Finding 2 is the normal case for a busy channel, not an edge: unread subjects = backlog.

Two new problems from the journal

Per-message cost. 300 channel messages with 200-character bodies, same fixture, same gate, drained every 100 so the cap is not hit:

ingest per message store size after
without coalesce 261 ms 0.9 ms 0.55 MB
with coalesce 25,574 ms 85 ms 3.4 MB

ChronicleCoalescingJournal.append does appendToStateJson + store.sync() on every operation, and one message costs three operations (accepted, prepared, delivered) with the full push body serialized twice. Startup replays the whole log; the body already notes it grows without bound. At channel volumes (Discord edits, world/presence churn are the stated targets) this is a ~100× slowdown on the ingest path and a store that grows ~10 KB per short message.

One storage fault halts all coalesced traffic until restart. I made store.sync() throw once. The message in flight was rejected with the error, and every coalesced message after it was rejected with Coalescing journal append failed; rebuild the controller from the journal before continuing, while plain channels/incoming and ordinary push/event kept flowing. Nothing re-creates the controller except a branch switch or a host restart, so a producer that has adopted coalesce goes deaf on a transient EIO while everyone else keeps working. If fail-closed is the intent, it needs an automatic rebuild path, or it needs to degrade to append-only delivery (RFC-006 principle 4, and the revision-7 capacity rule) rather than refuse.

Suggestion

The 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 unknown. That would also remove findings 2 and 5 and the fault wedge, and leave 1, 3b and 4 as small fixes.

Probe sources available on request; they are ~120 lines on top of the existing integration fixture.

@antra-tess

Copy link
Copy Markdown
Collaborator Author

Superseded by #197 (merged as 03c31d9): the in-context implementation of RFC-006 revision 7. Closing without merge.

@antra-tess antra-tess closed this Oct 2, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 7, 2026
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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants