Repository navigation
Coalescer: a retraction leaves an earlier render queued when two renders of one subject land in the same deferred queue #548
Description
On main (67aabff2), when two renders of one subject land in the same agent's deferred queue, the coalescer keeps track of only the second. A retraction of the subject then withdraws the second render and leaves the first in the queue. RFC-006 §6 has the host remove "any unconsumed occupant, pending batch, or rendering batch … of the subject from every context it was delivered to", and the first render is still unconsumed there.
slimepriestess's review of #168 found this on #168's branch (review). It is on main as well, without #168.
Where
When a render lands in another agent's deferred queue, its settlement sets the subject's occupant to the new placement (src/mcpl/push-coalescer.ts:867 at 67aabff2):
state.occupant = { eventId: occurrence.eventId, placement };If the subject already has an occupant in that queue, such as an earlier render that is still unread there, nothing withdraws it or keeps track of it, so a later retraction doesn't reach it.
Reproduction
The test below drives a PushCoalescer over a stub host that places every delivery, unread, in the deferred queue of an agent other whose turn is alive. It ran on Linux with Node 24.22.0 at 67aabff2.
- A deferred notice
n1(initial: true) opens a batch, and agentagentstarts assembling. The host holdsn1's render. - A second deferred notice
n2arrives during that render and opens a new batch (outcome: "first"). n1's render is released and lands inother's queue asdeferred-1.- The next assembly renders
n2's batch into the same queue asdeferred-2. - A retraction of the subject arrives (
outcome: "retracted").
The coalescer asks the host to remove deferred-2 only. Its audit records for the run are received:n1 received:n2 rendered:n1 rendered:n2 received:r1 removed:n2, and n1's render stays in other's queue after the subject is retracted.
The test, in a directory of its own under test/
// Two renders of one subject land in the same deferred queue, then the
// subject is retracted. Placed in a directory of its own under test/.
import { test } from 'node:test';
import { PushCoalescer, type CoalescerHost, type CoalescedOccurrence } from '../../src/mcpl/push-coalescer.js';
import { TS } from '../helpers/coalescing-fixture.js';
test('two renders of one subject into one deferred queue, then a retraction', async () => {
const delivered: string[] = [];
const removed: string[] = [];
const audits: string[] = [];
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
let started!: () => void;
const renderStarted = new Promise<void>((resolve) => { started = resolve; });
const host: CoalescerHost<null> = {
// Every delivery lands in the deferred queue of 'other', an agent whose
// turn is alive, and stays unread there.
isUnread: () => true,
remove: (placement) => { removed.push(placement.deferredId ?? placement.messageId ?? '?'); return true; },
deliver: async (occ) => { delivered.push(occ.eventId); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; },
wakeForBatch: async () => {}, wakeToRead: async () => {}, cancelWake: () => {}, authorized: () => true,
audience: async () => ['agent', 'other'],
// n1's render waits until it is released.
render: async (occ) => {
if (occ.eventId === 'n1') { started(); await gate; }
return { content: [{ type: 'text', text: `diff_${occ.eventId}` }] };
},
audit: (record) => { audits.push(`${String(record.kind)}:${String(record.eventId ?? '')}`); },
save: () => {},
};
const coalescer = new PushCoalescer<null>(host);
const occurrence = (eventId: string, flags: Partial<CoalescedOccurrence<null>>): CoalescedOccurrence<null> => ({
serverId: 'editor', binding: 'binding', scope: { kind: 'featureSet', id: 'doc' }, key: 'document', eventId, timestamp: TS,
retract: false, deferred: false, initial: false, content: [{ type: 'text', text: eventId }], event: null, ...flags,
});
const n1 = await coalescer.accept(occurrence('n1', { deferred: true, initial: true }));
const first = coalescer.assemble('agent');
await renderStarted;
const n2 = await coalescer.accept(occurrence('n2', { deferred: true }));
release();
await first;
await coalescer.assemble('agent');
const r1 = await coalescer.accept(occurrence('r1', { retract: true, content: [] }));
console.log(`outcomes n1=${n1.coalesce?.outcome} n2=${n2.coalesce?.outcome} r1=${r1.coalesce?.outcome}`);
console.log(`delivered ${JSON.stringify(delivered)} (deferred-1 is n1's render, deferred-2 is n2's)`);
console.log(`removed by the retraction ${JSON.stringify(removed)}`);
console.log(`audit ${audits.join(' ')}`);
});Its output:
outcomes n1=first n2=first r1=retracted
delivered ["n1","n2"] (deferred-1 is n1's render, deferred-2 is n2's)
removed by the retraction ["deferred-2"]
audit received:n1 received:n2 rendered:n1 rendered:n2 received:r1 removed:n2
Written by Meg-2646 (Claude Opus 5.5); posted by Daniel-2612 (Claude Opus 5.5).
Activity
fix(coalescing): an unread render stays when a later render or notice…