From a4835d9c0e7d520c4acec79c17a5bc2129b47360 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 01:31:52 -0400 Subject: [PATCH 01/12] fix(routing): refresh idle activity after delivery instead of queries ConversationRouter.route currently refreshes lastActivity while deciding where a message would go. A diagnostic lookup or failed context write therefore keeps a fork alive, while fixed-audience coalesced delivery bypasses that refresh entirely. Make route a pure query and expose touch(channelId, now) as the explicit activity command. Remove the unused query clock from IncomingMessageFacts and document the direct-caller migration. The framework touches after a successful context append, including fixed-audience delivery only when the recipient is still the active binding. Delivery to an older engagement cannot prolong its replacement. Self-echo identity policy (#47) stays outside this change. Add query immutability and delivery-boundary regressions, preserve ambient activity, cover failed writes and old-generation corrections, and move deterministic expiry coverage to touch. With the added tests and original source, the focused compiled suite gives 24 pass / 3 fail; changed source gives 27 pass / 0 fail. Build and diff checks pass. Full suite and independent review follow. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 4 ++ src/framework.ts | 6 ++ src/mcpl/conversation-router.ts | 20 ++++--- test/conversation-router.test.ts | 54 +++++++++++------- test/conversation-routing.test.ts | 93 +++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9a30b4c..1cf0cd586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Releases up to and including 0.7.3 predate this file; for their contents see ## Unreleased +### Breaking + +- **Direct ConversationRouter callers:** `route()` is now a pure query and no longer refreshes idle activity (#46). After successfully delivering a message to the bound fork, call `touch(channelId)`; deterministic clocks move from `route({ now })` to `touch(channelId, now)`. Framework-managed delivery performs this step automatically, including ambient messages and coalesced fixed-audience delivery to the current binding. Queries, failed writes, and delivery to an older engagement leave the current binding's clock unchanged. Bind rules, trigger rules, generation counters, and fork names are unchanged. No store migration or sibling dependency upgrade is required. + ## 0.19.0 — 2026-09-28 ### Added diff --git a/src/framework.ts b/src/framework.ts index 510b9183a..f65ce1480 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -6890,6 +6890,11 @@ export class AgentFramework { if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) { metadata.triggered = event.triggerInference ?? false; const id = target.getContextManager().addMessage('user', event.content, metadata); + // A correction may target an older engagement. Refresh only the + // binding whose agent actually received the message. + if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) { + this.conversationRouter.touch(event.channelId); + } this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (event.triggerInference) { this.pendingRequests.push({ @@ -7069,6 +7074,7 @@ export class AgentFramework { messageMetadata.triggered = trigger; const id = agent.getContextManager().addMessage('user', event.content, messageMetadata); + router.touch(event.channelId); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (trigger) { diff --git a/src/mcpl/conversation-router.ts b/src/mcpl/conversation-router.ts index e96cb5703..1dba1de96 100644 --- a/src/mcpl/conversation-router.ts +++ b/src/mcpl/conversation-router.ts @@ -95,8 +95,6 @@ export interface IncomingMessageFacts { mentioned: boolean; /** Channel classification (see classifyChannel). */ kind: ChannelKind; - /** Clock injection for tests; defaults to Date.now(). */ - now?: number; } export type RoutingDecision = @@ -149,17 +147,15 @@ export class ConversationRouter { } /** - * Decide where an incoming message goes. Pure read except for refreshing - * lastActivity on an existing binding — a 'spawn' decision does NOT create - * the binding; the framework calls bind() after the fork agent actually - * exists, so a failed spawn doesn't leave a dangling route. + * Decide where an incoming message goes without changing router state. + * The framework calls bind() after a fork exists and touch() after a + * message is delivered. Queries and failed deliveries leave idle clocks + * unchanged; a failed spawn doesn't leave a dangling route. */ route(facts: IncomingMessageFacts): RoutingDecision { - const now = facts.now ?? Date.now(); const existing = this.bindings.get(facts.channelId); if (existing) { - existing.lastActivity = now; return { kind: 'existing', agentName: existing.agentName, @@ -202,6 +198,14 @@ export class ConversationRouter { return binding; } + /** Record delivered activity on an existing binding. Does not bind a + * channel or advance its generation. Call after successful delivery, not + * when previewing a route. The optional clock supports deterministic use. */ + touch(channelId: string, now = Date.now()): void { + const binding = this.bindings.get(channelId); + if (binding) binding.lastActivity = now; + } + unbind(channelId: string): void { this.bindings.delete(channelId); } diff --git a/test/conversation-router.test.ts b/test/conversation-router.test.ts index 9a1697784..3e921a6fd 100644 --- a/test/conversation-router.test.ts +++ b/test/conversation-router.test.ts @@ -15,7 +15,7 @@ function makeRouter(overrides: Partial { const router = makeRouter(); - const decision = router.route({ channelId: 'slack:D1', mentioned: false, kind: 'dm', now: T0 }); + const decision = router.route({ channelId: 'slack:D1', mentioned: false, kind: 'dm' }); assert.equal(decision.kind, 'spawn'); if (decision.kind === 'spawn') { assert.equal(decision.generation, 1); @@ -26,19 +26,19 @@ test('DM message binds without a mention (default dm bind: always)', () => { test('channel message without mention stays unbound (default channel bind: mention)', () => { const router = makeRouter(); - const decision = router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel', now: T0 }); + const decision = router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel' }); assert.equal(decision.kind, 'unbound'); }); test('channel mention spawns a fork', () => { const router = makeRouter(); - const decision = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel', now: T0 }); + const decision = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(decision.kind, 'spawn'); }); test('bind rule never keeps DMs unbound', () => { const router = makeRouter({ bind: { dm: 'never' } }); - const decision = router.route({ channelId: 'slack:D1', mentioned: true, kind: 'dm', now: T0 }); + const decision = router.route({ channelId: 'slack:D1', mentioned: true, kind: 'dm' }); assert.equal(decision.kind, 'unbound'); }); @@ -48,17 +48,17 @@ test('bind rule never keeps DMs unbound', () => { test('spawn decision does not create the binding until bind() is called', () => { const router = makeRouter(); - const d1 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel', now: T0 }); + const d1 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(d1.kind, 'spawn'); assert.equal(router.getBinding('slack:C1'), undefined); // Framework failed to spawn → next mention proposes the same generation. - const d2 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel', now: T0 + 1 }); + const d2 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(d2.kind, 'spawn'); if (d2.kind === 'spawn') assert.equal(d2.generation, 1); if (d2.kind === 'spawn') router.bind('slack:C1', d2.agentName, d2.generation, T0 + 1); - const d3 = router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel', now: T0 + 2 }); + const d3 = router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel' }); assert.equal(d3.kind, 'existing'); }); @@ -70,11 +70,11 @@ test('bound channel: non-mentions land without triggering, mentions trigger', () const router = makeRouter(); router.bind('slack:C1', 'conversation-slack-C1-g1', 1, T0); - const ambient = router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel', now: T0 + 1 }); + const ambient = router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel' }); assert.equal(ambient.kind, 'existing'); if (ambient.kind === 'existing') assert.equal(ambient.trigger, false); - const mention = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel', now: T0 + 2 }); + const mention = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(mention.kind, 'existing'); if (mention.kind === 'existing') assert.equal(mention.trigger, true); }); @@ -82,7 +82,7 @@ test('bound channel: non-mentions land without triggering, mentions trigger', () test('bound DM: every message triggers', () => { const router = makeRouter(); router.bind('slack:D1', 'conversation-slack-D1-g1', 1, T0); - const decision = router.route({ channelId: 'slack:D1', mentioned: false, kind: 'dm', now: T0 + 1 }); + const decision = router.route({ channelId: 'slack:D1', mentioned: false, kind: 'dm' }); assert.equal(decision.kind, 'existing'); if (decision.kind === 'existing') assert.equal(decision.trigger, true); }); @@ -91,25 +91,41 @@ test('bound DM: every message triggers', () => { // TTL + generations // --------------------------------------------------------------------------- -test('expired() respects lastActivity refresh from route()', () => { +test('routing queries leave the binding and its expiry clock unchanged', () => { + const router = makeRouter({ idleTtlMs: 1000 }); + router.bind('slack:C1', 'fork-a', 3, T0); + const before = { ...router.getBinding('slack:C1')! }; + const generations = router.exportGenerations(); + + assert.equal(router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel' }).kind, 'existing'); + assert.equal(router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }).kind, 'existing'); + assert.deepEqual(router.getBinding('slack:C1'), before, 'previewing a route must not prolong an engagement'); + assert.deepEqual(router.exportGenerations(), generations); + assert.equal(router.expired(T0 + 1001).length, 1); +}); + +test('touch() refreshes expiry explicitly and leaves unbound channels alone', () => { const router = makeRouter({ idleTtlMs: 1000 }); router.bind('slack:C1', 'conversation-slack-C1-g1', 1, T0); - // Ambient traffic keeps the binding alive even without triggering. - router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel', now: T0 + 900 }); + // Delivery records activity independently of the routing query. + router.touch('slack:C1', T0 + 900); + router.touch('slack:unknown', T0 + 900); + assert.equal(router.getBinding('slack:unknown'), undefined); + assert.deepEqual(router.exportGenerations(), { 'slack:C1': 1 }); assert.deepEqual(router.expired(T0 + 1500), []); assert.equal(router.expired(T0 + 1901).length, 1); }); test('rebind after expiry gets a fresh generation and agent name', () => { const router = makeRouter({ idleTtlMs: 1000 }); - const d1 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel', now: T0 }); + const d1 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); if (d1.kind !== 'spawn') assert.fail('expected spawn'); router.bind('slack:C1', d1.agentName, d1.generation, T0); router.unbind('slack:C1'); // framework does this after the closure turn - const d2 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel', now: T0 + 5000 }); + const d2 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(d2.kind, 'spawn'); if (d2.kind === 'spawn') { assert.equal(d2.generation, 2); @@ -153,11 +169,11 @@ test('classifyChannel reads descriptor metadata and message channel_type', () => test('group DMs bind on any message but trigger only on mention by default', () => { const router = new ConversationRouter({ templateAgent: 'main' }); - const ambient = router.route({ channelId: 'slack:G1', mentioned: false, kind: 'groupDm', now: T0 }); + const ambient = router.route({ channelId: 'slack:G1', mentioned: false, kind: 'groupDm' }); assert.equal(ambient.kind, 'spawn', 'group DM binds without a mention'); assert.equal((ambient as { trigger: boolean }).trigger, false, 'no firehose replies in group DMs'); - const mention = router.route({ channelId: 'slack:G2', mentioned: true, kind: 'groupDm', now: T0 }); + const mention = router.route({ channelId: 'slack:G2', mentioned: true, kind: 'groupDm' }); assert.equal(mention.kind, 'spawn'); assert.equal((mention as { trigger: boolean }).trigger, true, 'mention triggers'); }); @@ -171,14 +187,14 @@ test('generation counters export/hydrate round-trips and never regresses', () => const restored = new ConversationRouter({ templateAgent: 'main' }); restored.hydrateGenerations(exported); - const decision = restored.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel', now: T0 }); + const decision = restored.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(decision.kind, 'spawn'); assert.equal((decision as { generation: number }).generation, 4, 'restart must not reuse generation names'); // Hydration never regresses a counter that advanced in the meantime. restored.bind('slack:D1', 'conversation-slack-D1-g5', 5, T0); restored.hydrateGenerations(exported); - const d1 = restored.route({ channelId: 'slack:D2', mentioned: false, kind: 'dm', now: T0 }); + const d1 = restored.route({ channelId: 'slack:D2', mentioned: false, kind: 'dm' }); assert.equal(d1.kind, 'spawn', 'sanity'); assert.equal(restored.exportGenerations()['slack:D1'], 5); }); diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index cdd3ed954..db3239b06 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -181,6 +181,99 @@ describe('Conversation routing', () => { await framework.stop(); }); + // Exercise the delivery boundary without scheduling inference. The same + // handler receives ordinary channel traffic and coalesced fixed-audience notices. + async function deliver(framework: AgentFramework, channelId: string, text: string, deliverTo?: string) { + const internals = framework as unknown as { + handleMcplChannelIncoming(event: unknown): Promise; + }; + return internals.handleMcplChannelIncoming({ + ...incomingEvent({ channelId, text, mentioned: true }), + triggerInference: false, + ...(deliverTo ? { deliverTo } : {}), + }); + } + + it('successful ambient delivery refreshes the bound fork idle clock', async () => { + const framework = await makeFramework(); + try { + await deliver(framework, 'slack:C1', 'first'); + const router = framework.getConversationRouter()!; + const binding = router.getBinding('slack:C1')!; + binding.lastActivity = 1; + const before = Date.now(); + framework.pushEvent({ + ...incomingEvent({ channelId: 'slack:C1', text: 'ambient detail' }), + triggerInference: false, + } as ProcessEvent); + await framework.runUntilIdle(); + + assert.ok(binding.lastActivity >= before, 'successful delivery records activity'); + assert.equal(membrane.calls.length, 0, 'ambient delivery needs no inference'); + assert.deepEqual(router.expired(before), []); + } finally { + await framework.stop(); + } + }); + + it('failed delivery leaves the bound fork idle clock unchanged', async () => { + const framework = await makeFramework(); + try { + await deliver(framework, 'slack:C1', 'first'); + const router = framework.getConversationRouter()!; + const binding = router.getBinding('slack:C1')!; + binding.lastActivity = 1; + const context = framework.getAgent(binding.agentName)!.getContextManager(); + const addMessage = context.addMessage; + context.addMessage = () => { throw new Error('injected context write failure'); }; + try { + await assert.rejects(deliver(framework, 'slack:C1', 'lost'), /injected context write failure/); + assert.equal(binding.lastActivity, 1, 'a routing decision is not delivered activity'); + } finally { + context.addMessage = addMessage; + } + } finally { + await framework.stop(); + } + }); + + it('fixed-audience coalesced delivery refreshes the current fork idle clock', async () => { + const framework = await makeFramework(); + try { + await deliver(framework, 'slack:C1', 'first'); + const router = framework.getConversationRouter()!; + const binding = router.getBinding('slack:C1')!; + binding.lastActivity = 1; + const before = Date.now(); + await deliver(framework, 'slack:C1', 'updated detail', binding.agentName); + assert.ok(binding.lastActivity >= before, 'delivery bypassing fresh routing still records activity'); + const { messages } = await framework.getAgent(binding.agentName)!.getContextManager().compile(); + assert.ok(JSON.stringify(messages).includes('updated detail')); + } finally { + await framework.stop(); + } + }); + + it('delivery to an older fork does not refresh a newer binding on the same channel', async () => { + const framework = await makeFramework(); + try { + await deliver(framework, 'slack:C1', 'first'); + const router = framework.getConversationRouter()!; + const oldName = router.getBinding('slack:C1')!.agentName; + router.unbind('slack:C1'); + await deliver(framework, 'slack:C1', 'new engagement'); + const binding = router.getBinding('slack:C1')!; + binding.lastActivity = 1; + await deliver(framework, 'slack:C1', 'old engagement correction', oldName); + + assert.equal(binding.lastActivity, 1, 'the correction belongs to the old engagement'); + const { messages } = await framework.getAgent(oldName)!.getContextManager().compile(); + assert.ok(JSON.stringify(messages).includes('old engagement correction')); + } finally { + await framework.stop(); + } + }); + it('idle TTL runs a closure turn, unbinds, disposes the fork, and the next message spawns g2', async () => { const IDLE_TTL_MS = 60_000; membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello!' }])); From 71173445e36e12ff942f19a18ad613e2e3fa7401 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 02:01:58 -0400 Subject: [PATCH 02/12] fix(routing): keep self echoes passive without losing counterpart batch causes Known self-authored channel echoes must not sustain a conversation fork. Preserve their accepted context in an existing engagement, while preventing new fork creation, inference requests, and idle-clock refresh. Identify self through chat:from-self or a matching nonempty author/botUserId; other bots and unknown identities remain counterparts. Veto before trigger callbacks, since a delivery-only guard cannot undo an EventGate debounce timer. A deferred batch may mix fresh counterpart input with a later self echo. Keep one optional activation occurrence separate from latest rendered content: undefined follows the latest active occurrence, null marks a self-only batch, and a retained occurrence carries the latest counterpart cause through passive updates. A later counterpart replaces the cause, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Persist the cause in both snapshots and receipt recovery; re-evaluate legacy latest-only batches without inventing a lost cause. Use the activation for wake/assembly and delivery-time activity attribution, so rendered older counterpart prose cannot revive a self-only batch. Freeze resolved push author identity at admission, including an explicit unknown author, so a later echo cannot retroactively turn an earlier unknown counterpart into self. Keep non-channel pushes and deployments without conversation routing unchanged. This stacks on issue #46 / PR #209 at a4835d9. Added 18 wire/integration regressions covering both identity signals, gate debounce, fixed-audience edits, retained context, unbound echoes, deferred assembly, mixed activity, genuine counterpart supersession, withdrawal/plain replacement, legacy wake verdicts, snapshot and receipt recovery, and compatibility controls. On isolated base a4835d9 they report 6 pass / 12 fail. Changed router/routing/coalescing/self-echo suites pass 111 / 0. A probe also caught and then covered retroactive author reclassification during development. Build and diff checks pass. Full compiled suite and independent routing/coalescer reviews follow. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 5 + src/framework.ts | 77 ++++++- src/mcpl/push-coalescer.ts | 65 ++++-- src/mcpl/push-handler.ts | 2 + test/conversation-self-echo.test.ts | 343 ++++++++++++++++++++++++++++ 5 files changed, 464 insertions(+), 28 deletions(-) create mode 100644 test/conversation-self-echo.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf0cd586..64853c3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Releases up to and including 0.7.3 predate this file; for their contents see - **Direct ConversationRouter callers:** `route()` is now a pure query and no longer refreshes idle activity (#46). After successfully delivering a message to the bound fork, call `touch(channelId)`; deterministic clocks move from `route({ now })` to `touch(channelId, now)`. Framework-managed delivery performs this step automatically, including ambient messages and coalesced fixed-audience delivery to the current binding. Queries, failed writes, and delivery to an older engagement leave the current binding's clock unchanged. Bind rules, trigger rules, generation counters, and fork names are unchanged. No store migration or sibling dependency upgrade is required. +### Fixed + +- **Hosts using conversation forks:** Self-authored channel echoes continue updating an existing fork's context, but cannot create a fork, request inference, or refresh its idle clock (#47). Self identity comes from `chat:from-self` or a nonempty author ID matching the message's `botUserId`; other bots and unknown identities keep normal routing. The veto precedes trigger callbacks so echoes cannot schedule delayed gate wakes. Non-channel pushes and deployments without conversation routing keep their existing behavior. +- Deferred channel batches retain the latest counterpart activation separately from their latest content. A self update preserves a pending counterpart cause; a later counterpart update replaces it, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Snapshots and receipt recovery preserve the cause, so fresh counterpart activity still counts in mixed batches while historical rendered text cannot renew a self-only batch. Legacy snapshots have only their recorded latest occurrence; an earlier overwritten cause cannot be reconstructed. + ## 0.19.0 — 2026-09-28 ### Added diff --git a/src/framework.ts b/src/framework.ts index f65ce1480..c1702272a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -265,6 +265,14 @@ const isAddressedMessage = ( _metadata?: Record, ): boolean => Array.isArray(tags) && tags.includes('chat:addressed'); +/** Identity, not automation: another bot is still a counterpart. */ +const isSelfAuthoredMessage = ( + tags: unknown, + authorId: unknown, + metadata?: Record, +): boolean => (Array.isArray(tags) && tags.includes('chat:from-self')) + || (typeof authorId === 'string' && authorId.length > 0 && authorId === metadata?.botUserId); + // parseProsePrefix moved to ./mcpl/prose-grammar.ts — ONE grammar shared with // the outgoing-stream router (Spec 14.3), so streamed chunks and delivered // envelopes can never disagree on what is a prefix. @@ -6864,6 +6872,8 @@ export class AgentFramework { eventId?: string; assemblingFor?: string; deliverTo?: string; + /** Host-owned deferred batch attribution, separate from rendered content. */ + conversationActivity?: boolean; }): Promise { const metadata: Record = { ...event.metadata, @@ -6888,15 +6898,16 @@ export class AgentFramework { const target = this.agents.get(event.deliverTo); if (!target) return undefined; if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) { - metadata.triggered = event.triggerInference ?? false; + const self = isSelfAuthoredMessage(event.tags, event.author.id, event.metadata); + metadata.triggered = !self && (event.triggerInference ?? false); const id = target.getContextManager().addMessage('user', event.content, metadata); // A correction may target an older engagement. Refresh only the // binding whose agent actually received the message. - if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) { + if ((event.conversationActivity ?? !self) && this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) { this.conversationRouter.touch(event.channelId); } this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); - if (event.triggerInference) { + if (!self && event.triggerInference) { this.pendingRequests.push({ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), channelId: event.channelId, @@ -7008,10 +7019,14 @@ export class AgentFramework { tags?: string[]; triggerInference?: boolean; coalescingSubject?: string; + conversationActivity?: boolean; }, messageMetadata: Record, ): Promise { const router = this.conversationRouter!; + const self = isSelfAuthoredMessage(event.tags, event.author.id, event.metadata); + // Echoes can update an existing engagement's context, but cannot start one. + if (self && !router.getBinding(event.channelId)) return; const descriptor = this.channelRegistry?.getDescriptor(event.channelId); const decision = router.route({ @@ -7070,11 +7085,11 @@ export class AgentFramework { // Respect an explicit server-config-level veto (shouldTriggerInference) // on top of the router's own trigger policy. - const trigger = decision.trigger && event.triggerInference !== false; + const trigger = !self && decision.trigger && event.triggerInference !== false; messageMetadata.triggered = trigger; const id = agent.getContextManager().addMessage('user', event.content, messageMetadata); - router.touch(event.channelId); + if (event.conversationActivity ?? !self) router.touch(event.channelId); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (trigger) { @@ -7378,7 +7393,8 @@ export class AgentFramework { const coalescer = new PushCoalescer({ isUnread: (p) => this.isUnreadPlacement(p), remove: (p) => this.removePlacement(p), - deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor), + deliver: (occ, materialized, assemblingFor, activation) => this.deliverCoalesced(occ, materialized, assemblingFor, activation), + isPassive: (occ) => !!this.conversationRouter && occ.scope.kind === 'channel' && this.isSelfConversationOccurrence(occ), wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ), cancelWake: (subject) => this.cancelCoalescedWakes(subject), authorized: (occ) => this.coalescedAuthorized(occ), @@ -7527,6 +7543,9 @@ export class AgentFramework { if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId); const origin = params.origin ?? {}; const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + const inheritedIdentity = c.channelId ? this.pushCoalescer.identity(coalescingSubjectKey( + serverId, this.coalescingBinding(serverId), { kind: 'channel', id: c.channelId }, c.key, + )) : undefined; return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -7543,9 +7562,14 @@ export class AgentFramework { content: params.payload.content, ...(c.channelId ? { identity: { + ...inheritedIdentity, ...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}), ...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}), - ...(str(origin.authorId) ? { author: { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } } : {}), + // Freeze even an unknown author now. A later self update must not + // retroactively change the identity of this pending activation. + author: str(origin.authorId) + ? { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } + : inheritedIdentity?.author ?? { id: '', name: str(origin.authorName) ?? 'Someone' }, }, } : {}), event: { lane: 'push', event }, @@ -7584,6 +7608,14 @@ export class AgentFramework { return true; } + private isSelfConversationOccurrence(occ: CoalescedOccurrence): boolean { + const metadata = occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin; + const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); + const identity = { ...this.pushCoalescer?.identity(subject), ...occ.identity }; + const authorId = identity.author?.id ?? (occ.event.lane === 'channel' ? occ.event.event.author.id : metadata?.authorId); + return isSelfAuthoredMessage(occ.tags, authorId, metadata); + } + private async coalescedAudience(occ: CoalescedOccurrence): Promise { if (occ.deliverTo) return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : []; const explicit = occ.event.event.targetAgents; @@ -7592,6 +7624,7 @@ export class AgentFramework { const router = this.conversationRouter; const bound = router.getBinding(occ.scope.id)?.agentName; if (bound) return this.agents.has(bound) ? [bound] : []; + if (this.isSelfConversationOccurrence(occ)) return []; // No fork yet: the same bind decision an incoming message would get. A // batch that qualifies spawns its fork now so it has someone to wake. const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {}; @@ -7619,11 +7652,16 @@ export class AgentFramework { occ: CoalescedOccurrence, materialized?: McplContentBlock[], assemblingFor?: string, + activation?: CoalescedOccurrence | null, ): Promise { const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {}; + // A rendered batch's latest content may be self-authored while a fresh + // counterpart occurrence still supplies its pending activity. Never infer + // activity from text the renderer happens to include from older history. + const activity = activation === undefined ? {} : { conversationActivity: activation !== null }; if (occ.event.lane === 'channel') { - const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) }; + const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...activity, ...(assemblingFor ? { assemblingFor } : {}) }; const placement = await this.handleMcplChannelIncoming(event); await this.dispatchProcessEventToModules(event as unknown as ProcessEvent); return placement; @@ -7653,6 +7691,7 @@ export class AgentFramework { coalescingSubject, eventId: occ.eventId, ...narrowing, + ...activity, ...(assemblingFor ? { assemblingFor } : {}), }; const placement = await this.handleMcplChannelIncoming(event); @@ -7679,6 +7718,7 @@ export class AgentFramework { const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); this.cancelCoalescedWakes(subject); if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return; + if (this.conversationRouter && occ.scope.kind === 'channel' && this.isSelfConversationOccurrence(occ)) return; const origin = occ.event.event.origin; // The wake carries the batch's channel so the turn freezes its reply // locus there (the materialized delivery at assembly wakes nobody). @@ -7809,6 +7849,8 @@ export class AgentFramework { */ private handleMcplPushEvent(event: McplPushEvent): CoalescingPlacement | undefined { const triggerChannel = this.derivePushEventChannel(event.origin); + const self = !!this.conversationRouter && !!triggerChannel + && isSelfAuthoredMessage(event.tags, event.origin?.authorId, event.origin); if (triggerChannel && this.channelRegistry) { this.channelRegistry.ensureChannelRegistered( event.serverId, @@ -7823,7 +7865,7 @@ export class AgentFramework { serverId: event.serverId, featureSet: event.featureSet, eventId: event.eventId, - triggered: event.triggerInference ?? false, + triggered: !self && (event.triggerInference ?? false), ...(event.tags ? { tags: event.tags } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }; @@ -7862,7 +7904,7 @@ export class AgentFramework { const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor }); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' }); - if (event.triggerInference) { + if (!self && event.triggerInference) { // Default broadcast excludes conversation forks (channel-driven). const targetAgents = event.targetAgents ?? [...this.agents.keys()].filter( @@ -12476,8 +12518,21 @@ export class AgentFramework { // Find shouldTriggerInference callback: // Per-server callback takes precedence; fall back to EventGate; fall back to no filter. - const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference + const configuredTriggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined); + const triggerFilter = (content: string, metadata: Record): boolean => { + const channelEvent = metadata.eventType === 'mcpl:channel-incoming'; + const channelPush = metadata.eventType === 'mcpl:push-event' + && (typeof metadata.coalesceChannelId === 'string' || !!this.derivePushEventChannel(metadata)); + const authorId = channelEvent + ? (metadata.author as { id?: unknown } | undefined)?.id + : metadata.authorId; + // The callback may arm a delayed gate wake, so delivery-time suppression + // alone is too late. Non-channel pushes keep their existing policy. + if (this.conversationRouter && (channelEvent || channelPush) + && isSelfAuthoredMessage(metadata.tags, authorId, metadata)) return false; + return configuredTriggerFilter ? configuredTriggerFilter(content, metadata) : true; + }; // Push events handler (Step 6) this.initializePushCoalescer(); diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 9536962f7..27b3635ef 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -90,6 +90,10 @@ export interface PushRenderResult { content: McplContentBlock[]; timestamp?: str interface DeferredBatch { /** Latest notice; its content is the fallback, its tags/event drive delivery. */ latest: CoalescedOccurrence; + /** Undefined: latest drives activity/wake policy (also the legacy format). + * Null: this batch contains only passive updates. Otherwise retain the + * latest active occurrence when a passive update changes the content. */ + activation?: CoalescedOccurrence | null; notices: DeferredNotice[]; dropped: number; /** Set on a batch restored from a snapshot whose render may have started. */ @@ -124,7 +128,7 @@ export interface CoalescingSnapshot { identity?: CoalescedOccurrence['identity']; occupant?: { eventId: string; placement: CoalescingPlacement }; audienceAgent?: string; - batch?: { latest: CoalescedOccurrence; notices: DeferredNotice[]; dropped: number; rendering?: boolean }; + batch?: { latest: CoalescedOccurrence; activation?: CoalescedOccurrence | null; notices: DeferredNotice[]; dropped: number; rendering?: boolean }; }>; } /** One receipt, written synchronously at acceptance (bridges the snapshot throttle). */ @@ -140,7 +144,7 @@ export interface CoalescingReceiptRecord { * the receipt that suppresses its retry), or `null` when the acceptance * left no batch — a retraction or plain replacement must be recoverable * too. Records replay in order, so the last one per subject wins. */ - batch: { latest: CoalescedOccurrence; notices: DeferredNotice[]; dropped: number } | null; + batch: { latest: CoalescedOccurrence; activation?: CoalescedOccurrence | null; notices: DeferredNotice[]; dropped: number } | null; } export interface CoalescerHost { @@ -150,8 +154,13 @@ export interface CoalescerHost { remove(placement: CoalescingPlacement): boolean; /** Ordinary delivery of the event; undefined when routing delivered nowhere. * A replacement is delivered as a fresh message after the unread prior was - * removed: it lands where a fresh event lands (§4.1 allows either place). */ - deliver(occurrence: CoalescedOccurrence, materialized?: McplContentBlock[], assemblingFor?: string): Promise; + * removed: it lands where a fresh event lands (§4.1 allows either place). + * For deferred materialization, activation names the pending activity + * cause, or null for a passive-only batch; content retains its own identity. */ + deliver(occurrence: CoalescedOccurrence, materialized?: McplContentBlock[], assemblingFor?: string, activation?: CoalescedOccurrence | null): Promise; + /** A passive deferred update changes content but cannot replace the batch's + * pending activity/wake cause. Re-evaluated for restored legacy batches. */ + isPassive?(occurrence: CoalescedOccurrence): boolean; /** Queue a wake for a batch that has no model-visible content yet. */ wakeForBatch(occurrence: CoalescedOccurrence): Promise; /** Withdraw unstarted wakes whose sole cause is this subject. */ @@ -360,7 +369,7 @@ export class PushCoalescer { // A producer retrying acknowledged work is also a chance to notice a // batch left asleep by a failed freeze: wake it now, without effects. const state = this.subjects.get(subject); - if (state?.batch && !state.rendering) { this.disarmRecovery(subject); await this.host.wakeForBatch(state.batch.latest); } + if (state?.batch && !state.rendering) { this.disarmRecovery(subject); await this.wakeBatch(state.batch); } return this.acknowledge(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId), duplicate, subject, occurrence.eventId); } const born = this.subjects.has(subject) ? undefined : (occurrence.initial && !occurrence.retract ? 'none' as const : 'unknown' as const); @@ -387,7 +396,7 @@ export class PushCoalescer { try { this.host.recordReceipt?.({ key: receiptKey, result: entry.result, at: entry.at, subject, ...(born ? { born } : {}), - batch: state.batch ? structuredClone({ latest: state.batch.latest as CoalescedOccurrence, notices: state.batch.notices, dropped: state.batch.dropped }) : null, + batch: state.batch ? structuredClone({ latest: state.batch.latest as CoalescedOccurrence, ...(state.batch.activation !== undefined ? { activation: state.batch.activation as CoalescedOccurrence | null } : {}), notices: state.batch.notices, dropped: state.batch.dropped }) : null, }); } catch (error) { // Not acknowledged: the work would be lost while its receipt suppressed @@ -440,7 +449,7 @@ export class PushCoalescer { // Authority and wake policy are the host's, re-evaluated at fire time. if (!this.host.authorized(state.batch.latest)) return; this.host.audit({ kind: 'recovery-wake', subject, eventId: state.batch.latest.eventId, attempt, delayMs: delay }); - void this.host.wakeForBatch(state.batch.latest).catch(() => { /* audited by the host */ }); + void this.wakeBatch(state.batch).catch(() => { /* audited by the host */ }); }, delay); (entry.timer as { unref?: () => void }).unref?.(); this.recovery.set(subject, entry); @@ -525,6 +534,22 @@ export class PushCoalescer { return state.consumedEventId || state.history !== 'none' ? 'appended' : 'first'; } + /** Keep content identity separate from the pending activity/wake cause. + * A later active occurrence replaces the cause, even when it vetoes waking. */ + private activationFor(batch: DeferredBatch): CoalescedOccurrence | null { + const activation = batch.activation === undefined ? batch.latest : batch.activation; + return activation && !this.host.isPassive?.(activation) ? activation : null; + } + + private async wakeBatch(batch: DeferredBatch): Promise { + const activation = this.activationFor(batch); + if (activation) await this.host.wakeForBatch(activation); + else { + const o = batch.latest; + this.host.cancelWake(coalescingSubjectKey(o.serverId, o.binding, o.scope, o.key)); + } + } + private async acceptDeferred(subject: string, state: SubjectState, occurrence: CoalescedOccurrence, occupantUnread: boolean): Promise { let outcome: CoalesceOutcome = 'first'; if (occupantUnread && state.occupant) { @@ -541,17 +566,20 @@ export class PushCoalescer { // Later notices stay in the batch's audience. occurrence = { ...occurrence, deliverTo: state.batch.latest.deliverTo }; } + const passive = this.host.isPassive?.(occurrence) ?? false; + const activation = passive ? (state.batch ? this.activationFor(state.batch) : null) : undefined; const notice: DeferredNotice = { eventId: occurrence.eventId, timestamp: occurrence.timestamp, ...(occurrence.data !== undefined ? { data: structuredClone(occurrence.data) } : {}) }; if (state.batch) { state.batch.notices.push(notice); if (state.batch.notices.length > this.maxNotices) { state.batch.notices.shift(); state.batch.dropped++; } state.batch.latest = occurrence; + state.batch.activation = activation; outcome = 'replaced'; } else { // Rule 1: a notice during a render opens a NEW batch ("first"). - state.batch = { latest: occurrence, notices: [notice], dropped: 0 }; + state.batch = { latest: occurrence, ...(activation !== undefined ? { activation } : {}), notices: [notice], dropped: 0 }; } - await this.host.wakeForBatch(occurrence); + await this.wakeBatch(state.batch); return outcome; } @@ -591,13 +619,13 @@ export class PushCoalescer { if (state.rendering) { // Another assembly froze this batch; share its outcome (vector 25). const rendering = state.rendering; - return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined; + return (await this.host.audience(this.activationFor(rendering.batch) ?? rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined; } const batch = state.batch; if (!batch) return undefined; // The audience lookup may spawn a fork. Under the lock no admission can // move the subject on meanwhile; the identity check guards the host. - const audience = await this.host.audience(batch.latest); + const audience = await this.host.audience(this.activationFor(batch) ?? batch.latest); if (!audience.includes(agentName) || state.batch !== batch || state.rendering) return undefined; state.batch = undefined; this.host.cancelWake(subject); @@ -675,7 +703,7 @@ export class PushCoalescer { if (!this.host.authorized(occurrence)) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; } this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty: content.length === 0 }); if (!content.length) return; // §5.2: nothing happened - const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor); + const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor, this.activationFor(batch)); if (placement?.deferredId) { // Landed in another agent's deferred queue (its turn is alive): still // unread there, so it stays replaceable and withdrawable. @@ -718,7 +746,7 @@ export class PushCoalescer { // An interrupted render is not replayed (§3.2): a batch that was // RENDERING keeps its fallback; a batch that was only pending renders // normally at the next assembly. - state.batch = { latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.rendering ? { noRender: true } : {}) }; + state.batch = { latest: s.batch.latest as CoalescedOccurrence, ...(s.batch.activation !== undefined ? { activation: s.batch.activation as CoalescedOccurrence | null } : {}), notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.rendering ? { noRender: true } : {}) }; } this.subjects.set(s.subject, state); } @@ -770,7 +798,7 @@ export class PushCoalescer { // afterwards: take the fallback. const state = this.subjects.get(r.subject) ?? { history: 'unknown' as const, touchedAt: now }; state.batch = r.batch - ? { latest: r.batch.latest as CoalescedOccurrence, notices: r.batch.notices, dropped: r.batch.dropped, noRender: true } + ? { latest: r.batch.latest as CoalescedOccurrence, ...(r.batch.activation !== undefined ? { activation: r.batch.activation as CoalescedOccurrence | null } : {}), notices: r.batch.notices, dropped: r.batch.dropped, noRender: true } : undefined; this.subjects.set(r.subject, state); } @@ -785,8 +813,8 @@ export class PushCoalescer { subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity, ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}), ...(s.occupant ? { occupant: s.occupant } : {}), - ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : {}) } } - : s.rendering ? { batch: { latest: s.rendering.batch.latest as CoalescedOccurrence, notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } } + ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence, ...(s.batch.activation !== undefined ? { activation: s.batch.activation as CoalescedOccurrence | null } : {}), notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : {}) } } + : s.rendering ? { batch: { latest: s.rendering.batch.latest as CoalescedOccurrence, ...(s.rendering.batch.activation !== undefined ? { activation: s.rendering.batch.activation as CoalescedOccurrence | null } : {}), notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } } : {}), })), }; @@ -794,7 +822,10 @@ export class PushCoalescer { /** Batches whose wake should be re-queued after a restart (their fallback is pending). */ pendingBatchOccurrences(): CoalescedOccurrence[] { - return [...this.subjects.values()].flatMap(s => s.batch ? [s.batch.latest] : []); + return [...this.subjects.values()].flatMap(s => { + const activation = s.batch ? this.activationFor(s.batch) : null; + return activation ? [activation] : []; + }); } suspend(): void { diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index d85df767a..38e68ced9 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -266,6 +266,8 @@ export class PushHandler { eventId: params.eventId, eventType: 'mcpl:push-event', ...(params.origin ?? {}), + // Channel scope can be declared by coalesce without any origin fields. + ...(params.coalesce?.channelId ? { coalesceChannelId: params.coalesce.channelId } : {}), ...(params.tags ? { tags: params.tags } : {}), }; triggerInference = this.shouldTriggerInference(textContent, metadata); diff --git a/test/conversation-self-echo.test.ts b/test/conversation-self-echo.test.ts new file mode 100644 index 000000000..f9ac0b115 --- /dev/null +++ b/test/conversation-self-echo.test.ts @@ -0,0 +1,343 @@ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { FrameworkConfig } from '../src/types/framework.js'; +import { fixture, crash } from './helpers/coalescing-fixture.js'; + +const conversations = { + templateAgent: 'agent', + bind: { channel: 'always' as const }, + trigger: { channel: 'always' as const }, + idleTtlMs: 60_000, +}; +type Fixture = Awaited>; + +async function setup(t: TestContext, options: Parameters[0] = {}) { + const f = await fixture({ + ...options, + framework: { conversations, ...options.framework }, + }); + t.after(f.close); + f.alwaysRespond(); + await f.register(); + return f; +} + +function plain(f: Fixture, id: string, text: string, extra: Record = {}) { + const { coalesce: _coalesce, ...message } = f.channel(id, text, {}, 'chat', id); + return { ...message, ...extra }; +} + +async function sendPlain(f: Fixture, id: string, text: string, extra: Record = {}) { + const result = await f.send('channels/incoming', { messages: [plain(f, id, text, extra)] }); + assert.equal(result.result.results[0].accepted, true); + await f.framework.runUntilIdle(); +} + +for (const [label, identity] of [ + ['self tag', { tags: ['chat:from-self'], author: { id: 'self', name: 'Self' } }], + ['matching botUserId', { tags: ['chat:from-bot'], author: { id: 'self', name: 'Self' }, metadata: { botUserId: 'self' } }], +] as const) { + test('conversation echoes identified by ' + label + ' keep context but do not wake, touch, or rebind', async (t) => { + const f = await setup(t); + await sendPlain(f, 'human', 'counterpart starts engagement'); + const router = f.framework.getConversationRouter()!; + const binding = router.getBinding('chat')!; + const oldActivity = Date.now() - 1000; + binding.lastActivity = oldActivity; + const calls = f.membrane.calls.length; + + await sendPlain(f, 'echo', 'self echo retained', identity); + assert.equal(binding.lastActivity, oldActivity, 'self speech must not renew idle lifetime'); + assert.equal(f.membrane.calls.length, calls, 'self echoes do not request inference'); + assert(f.context(binding.agentName).includes('self echo retained'), 'bound context retains accepted echoes'); + assert.deepEqual(router.expired(oldActivity + conversations.idleTtlMs + 1), [binding]); + + router.unbind('chat'); + await sendPlain(f, 'late-echo', 'self echo after engagement', identity); + assert.equal(router.getBinding('chat'), undefined, 'self echoes cannot create another engagement'); + assert.equal(router.exportGenerations().chat, 1, 'no generation consumed by the echo'); + assert.equal(f.membrane.calls.length, calls); + }); +} + +test('other bots and unknown self identity remain legitimate conversation activity', async (t) => { + const f = await setup(t); + await sendPlain(f, 'human', 'first'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + for (const [id, botUserId] of [['other-bot', 'self'], ['', '']] as const) { + binding.lastActivity = 1; + const calls = f.membrane.calls.length; + const before = Date.now(); + await sendPlain(f, 'message-' + id, 'counterpart ' + id, { + tags: ['chat:from-bot'], author: { id, name: 'Counterpart' }, metadata: { botUserId }, + }); + assert(binding.lastActivity >= before, 'being a bot or having an empty ID is not self identity'); + assert.equal(f.membrane.calls.length, calls + 1); + } +}); + +test('coalesced self replacement retains its receipt and fixed-audience context without renewing activity', async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return false; } } }); + await f.send('channels/incoming', { messages: [f.channel('first', 'unread original', { initial: true })] }); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + binding.lastActivity = 1; + const echo = { + ...f.channel('edit', 'self replacement'), + tags: ['chat:from-self'], author: { id: 'self', name: 'Self' }, + }; + const result = await f.send('channels/incoming', { messages: [echo] }); + assert.equal(result.result.results[0].coalesce.outcome, 'replaced'); + assert.equal(binding.lastActivity, 1, 'fixed-audience delivery also skips self activity'); + assert(f.context(binding.agentName).includes('self replacement')); + assert(!f.context(binding.agentName).includes('unread original')); + const retry = await f.send('channels/incoming', { messages: [echo] }); + assert.deepEqual(retry.result, result.result, 'coalescing retry keeps its receipt'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0); + assert.equal(filterCalls, 1, 'self echoes bypass callbacks that can schedule delayed gate work'); +}); + +for (const [label, identity] of [ + ['self tag', { tags: ['chat:from-self'] }], + ['matching botUserId', { tags: ['chat:from-bot'], origin: { authorId: 'self', botUserId: 'self' } }], +] as const) { + test('deferred channel-scoped ' + label + ' cannot pre-spawn a fork or arm a trigger callback', async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return true; } } }); + const result = await f.send('push/event', { + ...f.params('self', 'self fallback', { channelId: 'chat', key: 'self', deferred: true, initial: true }), + ...identity, + }); + assert.equal(result.result.accepted, true); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0); + assert.equal(filterCalls, 0); + + // Assembly for unrelated work also asks the coalescer for audiences. + f.framework.nudgeAgent('agent', 'test'); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + assert.equal(f.renders.length, 0, 'an unbound self batch has no fork to render for'); + assert(!f.context().includes('self fallback')); + }); +} + +test('self batches restored with an old wake verdict cannot create a conversation fork', async (t) => { + const config: Partial = {}; + const f = await fixture({ framework: config, server: { shouldTriggerInference: () => true } }); + t.after(f.close); + f.alwaysRespond(); + await f.register(); + await f.send('push/event', { + ...f.params('old-self', 'old self fallback', { channelId: 'chat', key: 'self', deferred: true }), + tags: ['chat:from-self'], + }); + // This snapshot predates conversation self filtering and carries triggerInference=true. + await f.framework.stop(); + config.conversations = conversations; + await f.create(); + await f.register(); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + assert.equal(f.membrane.calls.length, 0, 'replay cannot reuse the old self wake verdict'); + f.framework.nudgeAgent('agent', 'test'); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + assert.equal(f.renders.length, 0); +}); + +test('a bound self batch renders on an independent turn without refreshing lifetime or adding a wake', async (t) => { + const f = await setup(t); + await sendPlain(f, 'human', 'first'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + binding.lastActivity = Date.now() - 1000; + const activity = binding.lastActivity; + const calls = f.membrane.calls.length; + await f.send('push/event', { + ...f.params('self', 'self fallback', { channelId: 'chat', key: 'self', deferred: true }), + tags: ['chat:from-self'], + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'self does not wake the bound fork'); + assert.equal(f.renders.length, 0); + + f.framework.nudgeAgent(binding.agentName, 'test'); + await f.framework.runUntilIdle(); + assert.equal(f.renders.length, 1, 'accepted self context remains available to a later independent turn'); + assert(f.context(binding.agentName).includes('document_diff')); + assert.equal(binding.lastActivity, activity); + assert.equal(f.membrane.calls.length, calls + 1); +}); + + +test('a mixed deferred batch retains fresh counterpart activity, but old rendered text cannot revive a self-only batch', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + binding.lastActivity = 1; + const calls = f.membrane.calls.length; + const before = Date.now(); + await f.send('push/event', { + ...f.params('counterpart', 'fresh counterpart', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.send('push/event', { + ...f.params('self', 'self addition', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self' }, + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 1, 'self must preserve the pending counterpart wake'); + assert(binding.lastActivity >= before, 'fresh counterpart activity in the batch still counts'); + + binding.lastActivity = 1; + const afterMixed = f.membrane.calls.length; + f.renderer(async () => ({ content: [{ type: 'text', text: 'older counterpart history plus self update' }] })); + await f.send('push/event', { + ...f.params('self-only', 'self fallback', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, afterMixed); + f.framework.nudgeAgent(binding.agentName, 'test'); + await f.framework.runUntilIdle(); + assert(f.context(binding.agentName).includes('older counterpart history')); + assert.equal(binding.lastActivity, 1, 'rendered historical prose is not fresh counterpart activity'); +}); + +for (const recovery of ['snapshot', 'receipt bridge'] as const) { + test('mixed batch counterpart cause survives ' + recovery + ' recovery after a later self echo', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const calls = f.membrane.calls.length; + await f.send('push/event', { + ...f.params('counterpart', 'fresh counterpart', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.send('push/event', { + ...f.params('self', 'self addition', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self' }, + }); + if (recovery === 'snapshot') await f.framework.stop(); + else await crash(f); + await f.create(); + await f.register(); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 1, 'restart recovers the counterpart cause, not the last self verdict'); + assert(f.framework.getConversationRouter()!.getBinding('chat')); + // Receipt recovery takes the retained fallback rather than repeating a render. + assert.equal(f.renders.length, recovery === 'snapshot' ? 1 : 0); + }); +} + + +test('an unknown counterpart identity cannot become self when a later echo supplies the subject author', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + binding.lastActivity = 1; + const calls = f.membrane.calls.length; + await f.send('push/event', { + ...f.params('unknown', 'fresh unknown counterpart', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-bot'], origin: { botUserId: 'self' }, + }); + await f.send('push/event', { + ...f.params('self', 'self addition', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self', botUserId: 'self' }, + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 1, 'later identity must not reclassify the pending cause'); + assert(binding.lastActivity > 1); +}); + +test('a later genuine counterpart no-wake verdict supersedes the earlier cause, and self cannot revive it', async (t) => { + const f = await setup(t, { server: { shouldTriggerInference: (text: string) => text !== 'quiet counterpart' } }); + await sendPlain(f, 'start', 'initial counterpart'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + const activity = Date.now() - 1000; + binding.lastActivity = activity; + const calls = f.membrane.calls.length; + for (const [id, text, tags] of [ + ['wake', 'counterpart wake', ['chat:from-human']], + ['quiet', 'quiet counterpart', ['chat:from-human']], + ['self', 'self addition', ['chat:from-self']], + ] as const) { + await f.send('push/event', { ...f.params(id, text, { channelId: 'chat', key: 'mixed', deferred: true }), tags }); + } + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'the superseded wake is not sticky'); + assert.equal(binding.lastActivity, activity); + f.framework.nudgeAgent(binding.agentName, 'test'); + await f.framework.runUntilIdle(); + assert(binding.lastActivity > activity, 'the quiet counterpart is still fresh activity when delivered'); +}); + +for (const transition of ['withdrawal', 'plain replacement'] as const) { + test(transition + ' consumes the pending cause before a new self-only batch', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + const activity = Date.now() - 1000; + binding.lastActivity = activity; + const calls = f.membrane.calls.length; + await f.send('push/event', { + ...f.params('counterpart', 'fresh counterpart', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-human'], + }); + await f.send('push/event', { + ...f.params('clear', transition === 'withdrawal' ? '' : 'self replacement', { + channelId: 'chat', key: 'mixed', ...(transition === 'withdrawal' ? { retract: true } : {}), + }), + tags: ['chat:from-self'], + }); + await f.send('push/event', { + ...f.params('self-only', 'self fallback', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls); + f.framework.nudgeAgent(binding.agentName, 'test'); + await f.framework.runUntilIdle(); + assert.equal(binding.lastActivity, activity, 'cleared batch causes cannot leak into later self activity'); + }); +} + +test('self echoes cannot arm a debounced EventGate wake through either channel lane', async (t) => { + const f = await setup(t, { framework: { gate: { config: { + default: 'skip', + policies: [{ + name: 'debounce-all', + match: { scope: ['mcpl:channel-incoming', 'mcpl:push-event'] }, + behavior: { debounce: 20 }, + }], + } } } }); + await sendPlain(f, 'self-channel', 'self channel', { tags: ['chat:from-self'] }); + await f.send('push/event', { + ...f.params('self-push', 'self push', { channelId: 'chat', key: 'self', deferred: true }), + tags: ['chat:from-self'], + }); + await delay(60); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'self must be filtered before gate timers can be scheduled'); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); +}); + +test('non-channel self push traffic keeps its existing context and wake behavior', async (t) => { + const f = await setup(t); + await f.send('push/event', { ...f.params('status', 'background self status'), tags: ['chat:from-self'] }); + await f.framework.runUntilIdle(); + assert(f.context().includes('background self status')); + assert.equal(f.membrane.calls.length, 1); +}); + +test('without conversation routing, self channel messages keep existing behavior', async (t) => { + const f = await fixture(); + t.after(f.close); + f.alwaysRespond(); + await f.register(); + await sendPlain(f, 'self', 'ordinary primary self echo', { tags: ['chat:from-self'] }); + assert(f.context().includes('ordinary primary self echo')); + assert.equal(f.membrane.calls.length, 1); +}); From 4f7e8d55280517e3530cd7eb471357eb82ea4507 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 02:18:45 -0400 Subject: [PATCH 03/12] fix(routing): resolve coalesced author identity before trigger policy Felix reproduced an omitted-author update inheriting self identity only after the earlier trigger callback had already run. The callback can schedule delayed EventGate work even though subsequent delivery is passive. Defer the trigger decision for channel-scoped coalesced pushes into the serialized admission boundary. Freeze the resolved author once and pass it to the same trigger policy before accepting the occurrence. Concurrent arrivals now see the preceding subject identity before deciding whether to invoke effectful callbacks. Ordinary/non-channel push policy remains on its existing path, and original event origin stays separate from the derived author identity. Two real-wire regressions cover serial and concurrent inherited-self updates, then an explicit other-bot update as a positive control. Both fail on 7117344. Build and self-echo plus push-conversion tests pass 26 / 0. The separate in-flight coalescer recovery review finding remains under repair. Co-Authored-By: GPT-6 Astra --- src/framework.ts | 49 ++++++++++++++++++++--------- src/mcpl/push-handler.ts | 2 +- test/conversation-self-echo.test.ts | 30 ++++++++++++++++++ 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index c1702272a..879edda6a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7532,7 +7532,10 @@ export class AgentFramework { }); } - private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent): Promise { + private handleCoalescedPush( + serverId: string, params: PushEventParams, event: McplPushEvent, + triggerFilter: (content: string, metadata: Record) => boolean, + ): Promise { return this.admitCoalesced(async () => { if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000); validateCoalesceMember(params.coalesce, 'push'); @@ -7546,6 +7549,30 @@ export class AgentFramework { const inheritedIdentity = c.channelId ? this.pushCoalescer.identity(coalescingSubjectKey( serverId, this.coalescingBinding(serverId), { kind: 'channel', id: c.channelId }, c.key, )) : undefined; + const identity = c.channelId ? { + ...inheritedIdentity, + ...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}), + ...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}), + // Freeze even an unknown author now. A later self update must not + // retroactively change the identity of this pending activation. + author: str(origin.authorId) + ? { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } + : inheritedIdentity?.author ?? { id: '', name: str(origin.authorName) ?? 'Someone' }, + } : undefined; + if (identity) { + // Resolve identity and evaluate trigger policy under the same admission + // serialization. An earlier callback could schedule a gate wake before + // a preceding occurrence has supplied the subject's self author. + const text = event.content + .filter((b): b is ContentBlock & { type: 'text'; text: string } => b.type === 'text') + .map(b => b.text).join('\n'); + event = { ...event, triggerInference: triggerFilter(text, { + ...origin, serverId, featureSet: params.featureSet, eventId: params.eventId, + eventType: 'mcpl:push-event', coalesceChannelId: c.channelId, + authorId: identity.author.id, + ...(event.tags ? { tags: event.tags } : {}), + }) }; + } return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -7560,18 +7587,7 @@ export class AgentFramework { data: c.data, tags: event.tags, content: params.payload.content, - ...(c.channelId ? { - identity: { - ...inheritedIdentity, - ...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}), - ...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}), - // Freeze even an unknown author now. A later self update must not - // retroactively change the identity of this pending activation. - author: str(origin.authorId) - ? { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } - : inheritedIdentity?.author ?? { id: '', name: str(origin.authorName) ?? 'Someone' }, - }, - } : {}), + ...(identity ? { identity } : {}), event: { lane: 'push', event }, }); }); @@ -12540,8 +12556,11 @@ export class AgentFramework { this.featureSetManager, (event) => this.pushEvent(event as unknown as ProcessEvent), (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }), - triggerFilter, - (serverId, params, event) => this.handleCoalescedPush(serverId, params, event), + // Channel-scoped coalesced pushes evaluate this inside serialized + // admission, once inherited author identity has been resolved. + (content, metadata) => typeof metadata.coalesceChannelId === 'string' + ? false : triggerFilter(content, metadata), + (serverId, params, event) => this.handleCoalescedPush(serverId, params, event, triggerFilter), ); // Server-initiated inference router (Step 6) diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index 38e68ced9..e2844cd2d 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -267,7 +267,7 @@ export class PushHandler { eventType: 'mcpl:push-event', ...(params.origin ?? {}), // Channel scope can be declared by coalesce without any origin fields. - ...(params.coalesce?.channelId ? { coalesceChannelId: params.coalesce.channelId } : {}), + coalesceChannelId: params.coalesce?.channelId, ...(params.tags ? { tags: params.tags } : {}), }; triggerInference = this.shouldTriggerInference(textContent, metadata); diff --git a/test/conversation-self-echo.test.ts b/test/conversation-self-echo.test.ts index f9ac0b115..bca6349c6 100644 --- a/test/conversation-self-echo.test.ts +++ b/test/conversation-self-echo.test.ts @@ -341,3 +341,33 @@ test('without conversation routing, self channel messages keep existing behavior assert(f.context().includes('ordinary primary self echo')); assert.equal(f.membrane.calls.length, 1); }); + +for (const delivery of ['serial', 'concurrent'] as const) { + test('inherited self identity vetoes the trigger callback during ' + delivery + ' admission', async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return true; } } }); + const first = { + ...f.params('first-self', 'self', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { authorId: 'self', botUserId: 'self' }, + }; + const next = { + ...f.params('next-self', 'self update', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { botUserId: 'self' }, + }; + if (delivery === 'serial') { + await f.send('push/event', first); + await f.send('push/event', next); + } else { + await Promise.all([f.send('push/event', first), f.send('push/event', next)]); + } + assert.equal(filterCalls, 0, 'the trigger boundary must use the admitted, inherited self author'); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + await f.send('push/event', { + ...f.params('other', 'other bot update', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { authorId: 'other-bot', botUserId: 'self' }, + }); + assert.equal(filterCalls, 1, 'a genuine counterpart still reaches the configured trigger policy'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 1); + }); +} From 3b3db3ed877171b1eba714ccd75821b5f54f21e7 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 02:27:29 -0400 Subject: [PATCH 04/12] fix(coalescing): run trigger preparation only for new occurrences Felix reproduced a receipted omitted-author self update being retried after another author changed the subject identity. Resolving current identity under serialization was necessary but insufficient: the effectful trigger callback still ran before receipt replay was recognized, scheduling work for an already-admitted event. Add a prepare step to PushCoalescer.accept that runs under its lock only after receipt lookup rules out replay. Channel-scoped push trigger policy is evaluated there against the frozen author; existing receipts return without reinterpreting or rerunning the callback. Preparation changes host effect flags and leaves subject/receipt identity fixed. A real-wire self -> omitted-self -> other-bot -> exact-retry regression fails on 4f7e8d5 and passes here. Build and all self-echo/push-conversion tests pass 27 / 0. The in-flight frozen/pending recovery issue remains separately under repair. Co-Authored-By: GPT-6 Astra --- src/framework.ts | 16 ++++++++-------- src/mcpl/push-coalescer.ts | 13 +++++++++---- test/conversation-self-echo.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 879edda6a..d8307ea65 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7559,20 +7559,20 @@ export class AgentFramework { ? { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } : inheritedIdentity?.author ?? { id: '', name: str(origin.authorName) ?? 'Someone' }, } : undefined; - if (identity) { - // Resolve identity and evaluate trigger policy under the same admission - // serialization. An earlier callback could schedule a gate wake before - // a preceding occurrence has supplied the subject's self author. + const prepare = identity ? () => { + // Identity is frozen under admission serialization, and effectful + // trigger policy runs only after the coalescer rules out receipt + // replay. A retry cannot reinterpret an old self event as a newer author. const text = event.content .filter((b): b is ContentBlock & { type: 'text'; text: string } => b.type === 'text') .map(b => b.text).join('\n'); - event = { ...event, triggerInference: triggerFilter(text, { + event.triggerInference = triggerFilter(text, { ...origin, serverId, featureSet: params.featureSet, eventId: params.eventId, eventType: 'mcpl:push-event', coalesceChannelId: c.channelId, authorId: identity.author.id, ...(event.tags ? { tags: event.tags } : {}), - }) }; - } + }); + } : undefined; return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -7589,7 +7589,7 @@ export class AgentFramework { content: params.payload.content, ...(identity ? { identity } : {}), event: { lane: 'push', event }, - }); + }, prepare); }); } diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 27b3635ef..4d8f3ae7c 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -355,13 +355,15 @@ export class PushCoalescer { * Admit an occurrence that has already passed the lane's ordinary checks and * the coalesce-member validation. Returns the wire result, performing the * host effects (replace / remove / deliver) synchronously so a create, edit - * and delete cannot overtake one another. + * and delete cannot overtake one another. The optional prepare callback + * computes host effect flags only after receipt replay is ruled out; it + * must leave the occurrence's subject and receipt identity unchanged. */ - accept(occurrence: CoalescedOccurrence): Promise { - return this.locked(() => this.admit(occurrence)); + accept(occurrence: CoalescedOccurrence, prepare?: () => void): Promise { + return this.locked(() => this.admit(occurrence, prepare)); } - private async admit(occurrence: CoalescedOccurrence): Promise { + private async admit(occurrence: CoalescedOccurrence, prepare?: () => void): Promise { if (this.suspended) throw new CoalesceError('serverId', 'host is stopping', -32000); const subject = coalescingSubjectKey(occurrence.serverId, occurrence.binding, occurrence.scope, occurrence.key); const duplicate = this.receiptEntry(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId)); @@ -372,6 +374,9 @@ export class PushCoalescer { if (state?.batch && !state.rendering) { this.disarmRecovery(subject); await this.wakeBatch(state.batch); } return this.acknowledge(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId), duplicate, subject, occurrence.eventId); } + // Effectful host policy (for example an EventGate callback) belongs to + // new admission, never receipt replay against a newer subject identity. + prepare?.(); const born = this.subjects.has(subject) ? undefined : (occurrence.initial && !occurrence.retract ? 'none' as const : 'unknown' as const); const state = this.subjectFor(subject, occurrence); const occupantUnread = this.refreshOccupant(state); diff --git a/test/conversation-self-echo.test.ts b/test/conversation-self-echo.test.ts index bca6349c6..8ab27e5d5 100644 --- a/test/conversation-self-echo.test.ts +++ b/test/conversation-self-echo.test.ts @@ -371,3 +371,29 @@ for (const delivery of ['serial', 'concurrent'] as const) { assert.equal(f.membrane.calls.length, 1); }); } + +test('a receipted self update never re-evaluates trigger policy using a newer subject author', async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return true; } } }); + await f.send('push/event', { + ...f.params('first-self', 'self', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { authorId: 'self', botUserId: 'self' }, + }); + const selfUpdate = { + ...f.params('next-self', 'self update', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { botUserId: 'self' }, + }; + const accepted = await f.send('push/event', selfUpdate); + assert.equal(filterCalls, 0); + await f.send('push/event', { + ...f.params('other', 'other bot update', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { authorId: 'other-bot', botUserId: 'self' }, + }); + await f.framework.runUntilIdle(); + assert.equal(filterCalls, 1); + const replayed = await f.send('push/event', selfUpdate); + assert.deepEqual(replayed.result, accepted.result); + assert.equal(filterCalls, 1, 'receipt replay is not a new admission or a new identity decision'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 1); +}); From 944d43692257d538aa9bb5ccfc071aac042daee7 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 02:45:02 -0400 Subject: [PATCH 05/12] fix(coalescing): preserve interrupted frozen work beside newer batches Wren reproduced a counterpart render overlapping a newer self-only pending batch, followed by a crash. The snapshot and receipt bridge retained only the newer pending batch. Preserving its wake alone would still lose the older content, so retain frozen work separately with fallback, activation, and publication target. Recover frozen batches as ordered, no-render fallbacks ahead of newer pending work. Include them in pending-work observation, cancellation, wake restoration, receipt recovery, snapshots, and repeated interruptions. Each batch keeps its own cause; a newer quiet counterpart or self batch cannot erase a separate unconsumed frozen cause. Retraction and plain replacement supersede every older unconsumed batch. Keep the prior single-frozen snapshot representation for compatibility, adding frozen only when concurrent work must be retained. Settle already-published frozen work before rearming its cause, including an old conversation-fork namespace that is not re-registered after restart. Store the publication target before dispatch; read historical message metadata in bounded windows without resolving blobs. Also carry resolved subject author identity in receipts, so an omitted-author self update after receipt-only recovery cannot reach the trigger callback. Eight direct recovery tests cover snapshot and receipt bridges before/after publication, quiet successor causes, cancellation, and repeated interruption. The first seven produced 4 pass / 3 fail on 3b3db3e. Wire controls cover old-fork publication and author identity after plain/deferred snapshot/receipt recovery. Clean build and direct plus self-echo tests pass 34 / 0. The existing R9 render-start kill regression now passes after extending pendingBatches to observe recovered work. R17 timing failures were reproduced on the pre-recovery 3b3db3e head as well; final broader/full validation and independent re-review follow. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 1 + src/framework.ts | 44 ++++- src/mcpl/push-coalescer.ts | 223 +++++++++++++++---------- test/coalescer-render-recovery.test.ts | 166 ++++++++++++++++++ test/conversation-self-echo.test.ts | 64 +++++++ 5 files changed, 407 insertions(+), 91 deletions(-) create mode 100644 test/coalescer-render-recovery.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64853c3cd..493d09047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Releases up to and including 0.7.3 predate this file; for their contents see ### Fixed - **Hosts using conversation forks:** Self-authored channel echoes continue updating an existing fork's context, but cannot create a fork, request inference, or refresh its idle clock (#47). Self identity comes from `chat:from-self` or a nonempty author ID matching the message's `botUserId`; other bots and unknown identities keep normal routing. The veto precedes trigger callbacks so echoes cannot schedule delayed gate wakes. Non-channel pushes and deployments without conversation routing keep their existing behavior. +- Coalescer recovery retains interrupted frozen batches alongside newer pending work, including their fallback content, activity causes, and publication targets. Recovered frozen work materializes first, and already-published work is settled against its original fork namespace before any wake is restored. Receipt recovery also preserves resolved author identity. This closes the render-in-flight crash window where a later self update could discard an unconsumed counterpart cause. - Deferred channel batches retain the latest counterpart activation separately from their latest content. A self update preserves a pending counterpart cause; a later counterpart update replaces it, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Snapshots and receipt recovery preserve the cause, so fresh counterpart activity still counts in mixed batches while historical rendered text cannot renew a self-only batch. Legacy snapshots have only their recorded latest occurrence; an earlier overwritten cause cannot be reconstructed. ## 0.19.0 — 2026-09-28 diff --git a/src/framework.ts b/src/framework.ts index d8307ea65..6367f3c66 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -5,7 +5,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeF import { JsStore } from '@animalabs/chronicle'; import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane'; import { MembraneError } from '@animalabs/membrane'; -import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager'; +import { ContextManager, MessageStore, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager'; import type { CacheWireReceipt } from './kv-unified-wire.js'; import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js'; import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js'; @@ -7390,12 +7390,15 @@ export class AgentFramework { } private initializePushCoalescer(): void { + // Boot-only metadata cache for frozen batches published into forks that + // are no longer registered after restart. Avoid reading their blob bodies. + const publishedForks = new Map>(); const coalescer = new PushCoalescer({ isUnread: (p) => this.isUnreadPlacement(p), remove: (p) => this.removePlacement(p), deliver: (occ, materialized, assemblingFor, activation) => this.deliverCoalesced(occ, materialized, assemblingFor, activation), isPassive: (occ) => !!this.conversationRouter && occ.scope.kind === 'channel' && this.isSelfConversationOccurrence(occ), - wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ), + wakeForBatch: (occ, preserveExisting) => this.wakeForCoalescedBatch(occ, preserveExisting), cancelWake: (subject) => this.cancelCoalescedWakes(subject), authorized: (occ) => this.coalescedAuthorized(occ), audience: (occ) => this.coalescedAudience(occ), @@ -7413,7 +7416,7 @@ export class AgentFramework { this.coalescingRecentReceipts.push(record); this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts); }, - wasPublished: (subject, eventId) => { + wasPublished: (subject, eventId, publicationAgent) => { // Boot-time only: the occurrence's durable delivery identity // (subject + eventId in message metadata) in any agent's context. for (const agent of this.agents.values()) { @@ -7422,6 +7425,28 @@ export class AgentFramework { m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId)) return true; } catch { /* a context that cannot be read cannot prove publication */ } } + if (publicationAgent && !this.agents.has(publicationAgent)) { + let published = publishedForks.get(publicationAgent); + if (!published) { + published = new Set(); + try { + const messages = new MessageStore(this.store, { namespace: `conversations/${publicationAgent}` }); + for (let offset = 0; ;) { + const page = messages.getWindow(offset, 256, { resolveBlobs: false }); + for (const message of page.messages) { + const m = message.metadata; + if (typeof m?.coalescingSubject === 'string' && typeof m?.eventId === 'string') { + published.add(JSON.stringify([m.coalescingSubject, m.eventId])); + } + } + offset = page.startIndex + page.messages.length; + if (offset >= page.totalCount || !page.messages.length) break; + } + } catch { /* unavailable history cannot establish publication */ } + publishedForks.set(publicationAgent, published); + } + return published.has(JSON.stringify([subject, eventId])); + } return false; }, commit: () => this.commitCoalescingDurable(), @@ -7446,9 +7471,14 @@ export class AgentFramework { } catch (err) { console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err); } + publishedForks.clear(); this.pushCoalescer = coalescer; - // Batches restored from a snapshot still owe the agent a wake (§14 v41). - for (const occ of coalescer.pendingBatchOccurrences()) void this.wakeForCoalescedBatch(occ); + // Restore independent batch causes in order. An older frozen counterpart + // cannot be cancelled by a newer quiet batch on the same subject, and two + // causes must not race to create the same conversation fork. + void this.admitCoalesced(async () => { + for (const occ of coalescer.pendingBatchOccurrences()) await this.wakeForCoalescedBatch(occ, true); + }).catch(err => console.error('[coalescing] could not restore pending wakes:', err)); } /** @@ -7730,9 +7760,9 @@ export class AgentFramework { return placement; } - private async wakeForCoalescedBatch(occ: CoalescedOccurrence): Promise { + private async wakeForCoalescedBatch(occ: CoalescedOccurrence, preserveExisting = false): Promise { const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); - this.cancelCoalescedWakes(subject); + if (!preserveExisting) this.cancelCoalescedWakes(subject); if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return; if (this.conversationRouter && occ.scope.kind === 'channel' && this.isSelfConversationOccurrence(occ)) return; const origin = occ.event.event.origin; diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 4d8f3ae7c..0dda07903 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -98,11 +98,23 @@ interface DeferredBatch { dropped: number; /** Set on a batch restored from a snapshot whose render may have started. */ noRender?: boolean; + /** Where a frozen batch would publish; used only to settle recovery. */ + publicationAgent?: string; +} + +interface StoredDeferredBatch { + latest: CoalescedOccurrence; + activation?: CoalescedOccurrence | null; + notices: DeferredNotice[]; + dropped: number; + rendering?: boolean; + publicationAgent?: string; } interface Rendering { batch: DeferredBatch; cancelled: boolean; + recovered?: boolean; done: Promise; } @@ -111,6 +123,9 @@ interface SubjectState { occupant?: { eventId: string; placement: CoalescingPlacement }; batch?: DeferredBatch; rendering?: Rendering; + /** Interrupted frozen batches, oldest first, materialized as fallback before + * the newer pending batch. Further crashes must preserve every survivor. */ + recovered?: DeferredBatch[]; consumedEventId?: string; identity?: CoalescedOccurrence['identity']; /** Agent that received the last delivery; replacements and notices follow it. */ @@ -128,7 +143,9 @@ export interface CoalescingSnapshot { identity?: CoalescedOccurrence['identity']; occupant?: { eventId: string; placement: CoalescingPlacement }; audienceAgent?: string; - batch?: { latest: CoalescedOccurrence; activation?: CoalescedOccurrence | null; notices: DeferredNotice[]; dropped: number; rendering?: boolean }; + batch?: StoredDeferredBatch; + /** Frozen work concurrent with batch, or additional interrupted batches. */ + frozen?: StoredDeferredBatch[]; }>; } /** One receipt, written synchronously at acceptance (bridges the snapshot throttle). */ @@ -139,12 +156,16 @@ export interface CoalescingReceiptRecord { subject: string; /** History the subject was created with, for a subject first seen by this receipt. */ born?: SubjectState['history']; + /** Resolved identity after this admission, including plain occurrences. */ + identity?: CoalescedOccurrence['identity']; /** The subject's pending batch AFTER this acceptance: the accepted work * itself (so a crash before the snapshot flush cannot lose it while keeping * the receipt that suppresses its retry), or `null` when the acceptance * left no batch — a retraction or plain replacement must be recoverable * too. Records replay in order, so the last one per subject wins. */ - batch: { latest: CoalescedOccurrence; activation?: CoalescedOccurrence | null; notices: DeferredNotice[]; dropped: number } | null; + batch: StoredDeferredBatch | null; + /** All frozen work after acceptance; [] also records its cancellation. */ + frozen?: StoredDeferredBatch[]; } export interface CoalescerHost { @@ -162,7 +183,7 @@ export interface CoalescerHost { * pending activity/wake cause. Re-evaluated for restored legacy batches. */ isPassive?(occurrence: CoalescedOccurrence): boolean; /** Queue a wake for a batch that has no model-visible content yet. */ - wakeForBatch(occurrence: CoalescedOccurrence): Promise; + wakeForBatch(occurrence: CoalescedOccurrence, preserveExisting?: boolean): Promise; /** Withdraw unstarted wakes whose sole cause is this subject. */ cancelWake(subject: string): void; /** The batch's audience still holds the authority it was admitted under. */ @@ -188,7 +209,7 @@ export interface CoalescerHost { * delivery identity — subject + eventId in message metadata — is found * there)? A published occurrence is read from recovery on (the watermark * restarts at the head), whatever a stale record says about its history. */ - wasPublished?(subject: string, eventId: string): boolean; + wasPublished?(subject: string, eventId: string, publicationAgent?: string): boolean; now?(): number; } @@ -309,7 +330,7 @@ export class PushCoalescer { pendingBatches(): number { let n = 0; - for (const s of this.subjects.values()) if (s.batch) n++; + for (const s of this.subjects.values()) n += (s.batch ? 1 : 0) + (s.recovered?.length ?? 0); return n; } @@ -371,7 +392,7 @@ export class PushCoalescer { // A producer retrying acknowledged work is also a chance to notice a // batch left asleep by a failed freeze: wake it now, without effects. const state = this.subjects.get(subject); - if (state?.batch && !state.rendering) { this.disarmRecovery(subject); await this.wakeBatch(state.batch); } + if (state && (state.batch || state.recovered?.length) && !state.rendering) { this.disarmRecovery(subject); await this.wakeSubject(subject, state); } return this.acknowledge(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId), duplicate, subject, occurrence.eventId); } // Effectful host policy (for example an EventGate callback) belongs to @@ -401,7 +422,9 @@ export class PushCoalescer { try { this.host.recordReceipt?.({ key: receiptKey, result: entry.result, at: entry.at, subject, ...(born ? { born } : {}), - batch: state.batch ? structuredClone({ latest: state.batch.latest as CoalescedOccurrence, ...(state.batch.activation !== undefined ? { activation: state.batch.activation as CoalescedOccurrence | null } : {}), notices: state.batch.notices, dropped: state.batch.dropped }) : null, + ...(state.identity ? { identity: structuredClone(state.identity) } : {}), + batch: state.batch ? structuredClone(this.storeBatch(state.batch)) : null, + frozen: structuredClone(this.frozenBatches(state).map(b => this.storeBatch(b, true))), }); } catch (error) { // Not acknowledged: the work would be lost while its receipt suppressed @@ -450,11 +473,11 @@ export class PushCoalescer { // Disarm, but keep the attempt count: a freeze that fails again backs // off further. The count resets only on success or disposal. if (this.recovery.get(subject) === entry) entry.timer = undefined; - if (this.suspended || this.subjects.get(subject) !== state || !state.batch || state.rendering) return; + if (this.suspended || this.subjects.get(subject) !== state || (!state.batch && !state.recovered?.length) || state.rendering) return; // Authority and wake policy are the host's, re-evaluated at fire time. - if (!this.host.authorized(state.batch.latest)) return; - this.host.audit({ kind: 'recovery-wake', subject, eventId: state.batch.latest.eventId, attempt, delayMs: delay }); - void this.wakeBatch(state.batch).catch(() => { /* audited by the host */ }); + if (!this.workBatches(state).some(b => this.host.authorized(b.latest))) return; + this.host.audit({ kind: 'recovery-wake', subject, eventId: this.workBatches(state)[0]?.latest.eventId, attempt, delayMs: delay }); + void this.wakeSubject(subject, state).catch(() => { /* audited by the host */ }); }, delay); (entry.timer as { unref?: () => void }).unref?.(); this.recovery.set(subject, entry); @@ -476,6 +499,11 @@ export class PushCoalescer { private dropBatches(subject: string, state: SubjectState): boolean { this.clearRecovery(subject); let dropped = false; + if (state.recovered?.length) { + for (const batch of state.recovered) this.host.audit({ kind: 'displaced', subject, eventId: batch.latest.eventId, batch: true }); + state.recovered = undefined; + dropped = true; + } if (state.batch) { this.host.audit({ kind: 'displaced', subject, eventId: state.batch.latest.eventId, batch: true }); state.batch = undefined; dropped = true; } if (state.rendering) { this.host.audit({ kind: 'render-cancelled', subject, eventId: state.rendering.batch.latest.eventId }); @@ -546,12 +574,39 @@ export class PushCoalescer { return activation && !this.host.isPassive?.(activation) ? activation : null; } - private async wakeBatch(batch: DeferredBatch): Promise { - const activation = this.activationFor(batch); - if (activation) await this.host.wakeForBatch(activation); - else { - const o = batch.latest; - this.host.cancelWake(coalescingSubjectKey(o.serverId, o.binding, o.scope, o.key)); + private workBatches(state: SubjectState): DeferredBatch[] { + return [...(state.recovered ?? []), ...(state.batch ? [state.batch] : [])]; + } + + private frozenBatches(state: SubjectState): DeferredBatch[] { + return [...(state.rendering ? [state.rendering.batch] : []), ...(state.recovered ?? [])]; + } + + private storeBatch(batch: DeferredBatch, rendering = !!batch.noRender): StoredDeferredBatch { + return { + latest: batch.latest as CoalescedOccurrence, notices: batch.notices, dropped: batch.dropped, + ...(batch.activation !== undefined ? { activation: batch.activation as CoalescedOccurrence | null } : {}), + ...(batch.publicationAgent ? { publicationAgent: batch.publicationAgent } : {}), + ...(rendering ? { rendering: true } : {}), + }; + } + + private restoreBatch(batch: StoredDeferredBatch, noRender = !!batch.rendering): DeferredBatch { + return { + latest: batch.latest as CoalescedOccurrence, notices: batch.notices, dropped: batch.dropped, + ...(batch.activation !== undefined ? { activation: batch.activation as CoalescedOccurrence | null } : {}), + ...(batch.publicationAgent ? { publicationAgent: batch.publicationAgent } : {}), + ...(noRender ? { noRender: true } : {}), + }; + } + + private async wakeSubject(subject: string, state: SubjectState): Promise { + this.host.cancelWake(subject); + for (const batch of this.workBatches(state)) { + const activation = this.activationFor(batch); + // Different frozen/pending batches have independent causes. A quiet + // newer batch must not cancel an older, still-unconsumed obligation. + if (activation) await this.host.wakeForBatch(activation, true); } } @@ -584,7 +639,7 @@ export class PushCoalescer { // Rule 1: a notice during a render opens a NEW batch ("first"). state.batch = { latest: occurrence, ...(activation !== undefined ? { activation } : {}), notices: [notice], dropped: 0 }; } - await this.wakeBatch(state.batch); + await this.wakeSubject(subject, state); return outcome; } @@ -599,75 +654,69 @@ export class PushCoalescer { * materializes the admitted fallback (§5.3). */ async assemble(agentName: string): Promise { - if (this.suspended) return; - const work: Promise[] = []; - for (const subject of [...this.subjects.keys()]) { - // The handle is wrapped: a bare promise returned through `locked` would - // be flattened by `then`, holding the lock until the render settled — - // and the settlement itself needs the lock. + await Promise.all([...this.subjects.keys()].map(subject => this.assembleSubject(subject, agentName))); + this.persist(); + } + + private async assembleSubject(subject: string, agentName: string): Promise { + while (!this.suspended) { const started = await this.locked(() => this.freeze(subject, agentName)); - if (started) work.push(started.done); + if (!started) return; + await started.done; + // Recovery seals older interrupted batches as fallback, then lets the + // newer pending batch assemble in the same turn. A normal live render + // still closes only its own batch; arrivals during it await a later turn. + if (!started.recovered) return; } - await Promise.all(work); - this.persist(); } - /** - * Under the lock: decide whether `subject`'s batch renders for `agentName`, - * freeze it, make the render-start durable, re-check authority, and start - * the RPC. Returns the render's completion (shared by every assembly that - * waits on this batch, vector 25) or undefined when nothing was started. - */ - private async freeze(subject: string, agentName: string): Promise<{ done: Promise } | undefined> { + private async freeze(subject: string, agentName: string): Promise<{ done: Promise; recovered: boolean } | undefined> { const state = this.subjects.get(subject); if (!state || this.suspended) return undefined; if (state.rendering) { - // Another assembly froze this batch; share its outcome (vector 25). const rendering = state.rendering; - return (await this.host.audience(this.activationFor(rendering.batch) ?? rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined; + return (await this.host.audience(this.activationFor(rendering.batch) ?? rendering.batch.latest)).includes(agentName) && state.rendering === rendering + ? { done: rendering.done, recovered: !!rendering.recovered } : undefined; } - const batch = state.batch; + const recovered = !!state.recovered?.length; + const batch = recovered ? state.recovered![0] : state.batch; if (!batch) return undefined; - // The audience lookup may spawn a fork. Under the lock no admission can - // move the subject on meanwhile; the identity check guards the host. const audience = await this.host.audience(this.activationFor(batch) ?? batch.latest); - if (!audience.includes(agentName) || state.batch !== batch || state.rendering) return undefined; - state.batch = undefined; + if (!audience.includes(agentName) || (recovered ? state.recovered?.[0] !== batch : state.batch !== batch) || state.rendering) return undefined; + if (recovered) state.recovered!.shift(); + else state.batch = undefined; this.host.cancelWake(subject); if (!this.host.authorized(batch.latest)) { this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId }); - return undefined; + return { done: Promise.resolve(), recovered }; } - const rendering: Rendering = { batch, cancelled: false, done: Promise.resolve() }; + batch.publicationAgent = agentName; + const rendering: Rendering = { batch, cancelled: false, recovered, done: Promise.resolve() }; state.rendering = rendering; - // The render-start boundary is recoverable BEFORE the RPC: a restart - // then takes the fallback instead of asking the server a second time. + const putBack = () => { + state.rendering = undefined; + if (recovered) (state.recovered ??= []).unshift(batch); + else state.batch = batch; + }; try { if (this.host.saveNow) this.host.saveNow(this.snapshot()); else this.persist(); await this.host.commit?.(); } catch (error) { this.host.audit({ kind: 'persist-failed', subject, eventId: batch.latest.eventId, error: String(error) }); - state.rendering = undefined; - batch.noRender = true; // cannot prove a render never started → fallback - state.batch = batch; - // The wake was withdrawn above; acknowledged work must not sleep until - // unrelated traffic happens by. Re-wake with backoff, so a persistent - // storage outage does not become a tight loop of model turns. + batch.noRender = true; + putBack(); this.scheduleRecovery(subject, state); return undefined; } this.clearRecovery(subject); - if (this.suspended) { state.rendering = undefined; state.batch = batch; return undefined; } - // Authority (grant, registration, binding) is re-checked immediately - // before dispatch: a server id reassigned to another endpoint during the - // commit must not receive this batch's notices. + if (this.suspended) { putBack(); return undefined; } if (!this.host.authorized(batch.latest)) { this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId }); state.rendering = undefined; - return undefined; + return { done: Promise.resolve(), recovered }; } rendering.done = this.render(subject, state, rendering, agentName); - return { done: rendering.done }; + return { done: rendering.done, recovered }; } private async render(subject: string, state: SubjectState, rendering: Rendering, assemblingFor: string): Promise { @@ -747,12 +796,11 @@ export class PushCoalescer { audienceAgent: s.audienceAgent ?? s.occupant?.placement.agent, touchedAt: now, }; - if (s.batch) { - // An interrupted render is not replayed (§3.2): a batch that was - // RENDERING keeps its fallback; a batch that was only pending renders - // normally at the next assembly. - state.batch = { latest: s.batch.latest as CoalescedOccurrence, ...(s.batch.activation !== undefined ? { activation: s.batch.activation as CoalescedOccurrence | null } : {}), notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.rendering ? { noRender: true } : {}) }; - } + // Legacy snapshots put a sole frozen batch in batch with rendering=true. + // New snapshots also retain concurrent frozen work alongside a pending batch. + if (s.batch?.rendering) state.recovered = [this.restoreBatch(s.batch, true)]; + else if (s.batch) state.batch = this.restoreBatch(s.batch); + if (s.frozen?.length) (state.recovered ??= []).push(...s.frozen.map(b => this.restoreBatch(b, true))); this.subjects.set(s.subject, state); } this.settlePublished(); @@ -767,13 +815,14 @@ export class PushCoalescer { private settlePublished(): void { if (!this.host.wasPublished) return; for (const [subject, state] of this.subjects) { - const batch = state.batch; - if (!batch?.noRender) continue; - if (this.host.wasPublished(subject, batch.latest.eventId)) { - state.batch = undefined; + const settled = (batch: DeferredBatch): boolean => { + if (!this.host.wasPublished!(subject, batch.latest.eventId, batch.publicationAgent)) return false; state.history = 'some'; state.consumedEventId = batch.latest.eventId; - } + return true; + }; + if (state.batch?.noRender && settled(state.batch)) state.batch = undefined; + if (state.recovered) state.recovered = state.recovered.filter(b => !settled(b)); } } @@ -802,9 +851,11 @@ export class PushCoalescer { // record was written, but the snapshot cannot say whether it did // afterwards: take the fallback. const state = this.subjects.get(r.subject) ?? { history: 'unknown' as const, touchedAt: now }; - state.batch = r.batch - ? { latest: r.batch.latest as CoalescedOccurrence, ...(r.batch.activation !== undefined ? { activation: r.batch.activation as CoalescedOccurrence | null } : {}), notices: r.batch.notices, dropped: r.batch.dropped, noRender: true } - : undefined; + if (r.identity) state.identity = r.identity; + else if (r.batch?.latest.identity) state.identity = mergeIdentity(state.identity, r.batch.latest.identity); + state.batch = r.batch ? this.restoreBatch(r.batch, true) : undefined; + if (r.frozen !== undefined) state.recovered = r.frozen.map(b => this.restoreBatch(b, true)); + else if (r.batch === null) state.recovered = undefined; // legacy explicit withdrawal/replacement this.subjects.set(r.subject, state); } this.settlePublished(); @@ -814,23 +865,27 @@ export class PushCoalescer { return { version: 1, receipts: [...this.receipts], - subjects: [...this.subjects].map(([subject, s]) => ({ - subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity, - ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}), - ...(s.occupant ? { occupant: s.occupant } : {}), - ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence, ...(s.batch.activation !== undefined ? { activation: s.batch.activation as CoalescedOccurrence | null } : {}), notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : {}) } } - : s.rendering ? { batch: { latest: s.rendering.batch.latest as CoalescedOccurrence, ...(s.rendering.batch.activation !== undefined ? { activation: s.rendering.batch.activation as CoalescedOccurrence | null } : {}), notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } } - : {}), - })), + subjects: [...this.subjects].map(([subject, s]) => { + const frozen = this.frozenBatches(s).map(b => this.storeBatch(b, true)); + // Preserve the legacy representation for a sole frozen batch. + const batch = s.batch ? this.storeBatch(s.batch) : frozen.shift(); + return { + subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity, + ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}), + ...(s.occupant ? { occupant: s.occupant } : {}), + ...(batch ? { batch } : {}), + ...(frozen.length ? { frozen } : {}), + }; + }), }; } - /** Batches whose wake should be re-queued after a restart (their fallback is pending). */ + /** Every still-pending cause, including interrupted frozen work. */ pendingBatchOccurrences(): CoalescedOccurrence[] { - return [...this.subjects.values()].flatMap(s => { - const activation = s.batch ? this.activationFor(s.batch) : null; + return [...this.subjects.values()].flatMap(s => this.workBatches(s).flatMap(batch => { + const activation = this.activationFor(batch); return activation ? [activation] : []; - }); + })); } suspend(): void { @@ -851,7 +906,7 @@ export class PushCoalescer { if (this.subjects.size <= this.maxSubjects) return; // Evict the oldest idle subjects; their history degrades to `unknown` // (an untracked subject) which is the conservative outcome (§3.3). - const idle = [...this.subjects].filter(([, s]) => !s.occupant && !s.batch && !s.rendering).sort((a, b) => a[1].touchedAt - b[1].touchedAt); + const idle = [...this.subjects].filter(([, s]) => !s.occupant && !s.batch && !s.rendering && !s.recovered?.length).sort((a, b) => a[1].touchedAt - b[1].touchedAt); for (const [key] of idle.slice(0, Math.max(0, this.subjects.size - this.maxSubjects))) this.subjects.delete(key); } } diff --git a/test/coalescer-render-recovery.test.ts b/test/coalescer-render-recovery.test.ts new file mode 100644 index 000000000..7ef3dee67 --- /dev/null +++ b/test/coalescer-render-recovery.test.ts @@ -0,0 +1,166 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + PushCoalescer, coalescingSubjectKey, + type CoalescedOccurrence, type CoalescingSnapshot, type CoalescingReceiptRecord, +} from '../src/mcpl/push-coalescer.js'; + +type Event = { self: boolean; wake: boolean }; +type Occurrence = CoalescedOccurrence; +const subject = coalescingSubjectKey('server', 'binding', { kind: 'channel', id: 'chat' }, 'topic'); + +function occurrence(id: string, self = false, wake = !self): Occurrence { + return { + serverId: 'server', binding: 'binding', scope: { kind: 'channel', id: 'chat' }, + key: 'topic', eventId: id, timestamp: new Date().toISOString(), + deferred: true, retract: false, initial: true, + content: [{ type: 'text', text: 'fallback:' + id }], event: { self, wake }, + }; +} + +function harness(published = new Set()) { + const delivered: Array<{ id: string; body: string; activation: string | null }> = []; + const wakes = new Set(); + let saved: CoalescingSnapshot | undefined; + const receipts: CoalescingReceiptRecord[] = []; + let release: ((value: { content: Array<{ type: 'text'; text: string }> }) => void) | undefined; + let started!: () => void; + const rendering = new Promise(resolve => { started = resolve; }); + let hold = false; + const coalescer = new PushCoalescer({ + isUnread: () => false, + remove: () => false, + isPassive: o => o.event.self, + deliver: async (o, materialized, _forAgent, activation) => { + published.add(o.eventId); + delivered.push({ + id: o.eventId, body: JSON.stringify(materialized ?? o.content), + activation: activation === null ? null : (activation ?? (o.event.self ? null : o))?.eventId ?? null, + }); + return { agent: 'agent', messageId: o.eventId }; + }, + wakeForBatch: async (o, preserveExisting?: boolean) => { + if (!preserveExisting) wakes.clear(); + if (o.event.wake && !o.event.self) wakes.add(o.eventId); + }, + cancelWake: () => { wakes.clear(); }, + authorized: () => true, + audience: async () => ['agent'], + render: async (o) => { + if (!hold) return { content: [{ type: 'text', text: 'render:' + o.eventId }] }; + started(); + return new Promise(resolve => { release = resolve; }); + }, + audit: () => {}, + save: () => {}, + saveNow: snapshot => { saved = structuredClone(snapshot); receipts.length = 0; }, + recordReceipt: receipt => { receipts.push(structuredClone(receipt)); }, + commit: async () => {}, + wasPublished: (_subject, id) => published.has(id), + }); + return { + coalescer, delivered, wakes, published, receipts, rendering, + hold: () => { hold = true; }, + release: () => { hold = false; release!({ content: [{ type: 'text', text: 'completed active render' }] }); }, + saved: () => structuredClone(saved!), + }; +} + +for (const durable of ['snapshot', 'receipt bridge'] as const) { + for (const consumed of [false, true]) { + test(durable + ' preserves a frozen counterpart before consumption and settles it after publication: ' + consumed, async () => { + const live = harness(); + await live.coalescer.accept(occurrence('active')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('self-during-render', true)); + const snapshot = durable === 'snapshot' ? structuredClone(live.coalescer.snapshot()) : live.saved(); + const receipts = durable === 'receipt bridge' ? structuredClone(live.receipts) : []; + + if (!consumed) live.coalescer.suspend(); + live.release(); + await run; + assert.equal(live.published.has('active'), consumed); + const restored = harness(live.published); + restored.coalescer.restore(snapshot); + restored.coalescer.restoreReceipts(receipts); + const causes = restored.coalescer.pendingBatchOccurrences().map(o => o.eventId); + assert.equal(causes.includes('active'), !consumed, 'unconsumed work keeps its cause; published work clears it'); + assert(!causes.includes('self-during-render')); + await restored.coalescer.assemble('agent'); + assert.deepEqual(restored.delivered.map(d => d.id), consumed ? ['self-during-render'] : ['active', 'self-during-render']); + if (!consumed) { + assert(restored.delivered[0]!.body.includes('fallback:active'), 'the interrupted content survives with its cause'); + assert.equal(restored.delivered[0]!.activation, 'active'); + } + assert.equal(restored.delivered.at(-1)!.activation, null, 'the later self batch never borrows consumed activity'); + assert.deepEqual(restored.coalescer.pendingBatchOccurrences(), []); + await restored.coalescer.assemble('agent'); + assert.equal(restored.delivered.length, consumed ? 1 : 2, 'each batch materializes once'); + }); + } +} + +test('a newer no-wake counterpart batch cannot erase a separate interrupted batch cause', async () => { + const live = harness(); + await live.coalescer.accept(occurrence('active')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('quiet-counterpart', false, false)); + const snapshot = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + + const restored = harness(); + restored.coalescer.restore(snapshot); + assert(restored.coalescer.pendingBatchOccurrences().some(o => o.eventId === 'active')); + await restored.coalescer.assemble('agent'); + assert.deepEqual(restored.delivered.map(d => d.id), ['active', 'quiet-counterpart']); + assert.deepEqual(restored.delivered.map(d => d.activation), ['active', 'quiet-counterpart']); +}); + +for (const operation of ['retract', 'plain'] as const) { + test(operation + ' supersedes both interrupted frozen work and the later pending batch', async () => { + const live = harness(); + await live.coalescer.accept(occurrence('active')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('self-during-render', true)); + const snapshot = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + + const restored = harness(); + restored.coalescer.restore(snapshot); + await restored.coalescer.accept({ + ...occurrence('replacement', true), deferred: false, initial: false, + retract: operation === 'retract', content: operation === 'retract' ? [] : [{ type: 'text', text: 'replacement' }], + }); + await restored.coalescer.assemble('agent'); + assert(!restored.delivered.some(d => d.id === 'active' || d.id === 'self-during-render')); + assert.deepEqual(restored.coalescer.pendingBatchOccurrences(), []); + assert.equal(restored.wakes.size, 0); + }); +} + +test('a second interruption preserves frozen and pending batches with their separate causes', async () => { + const live = harness(); + await live.coalescer.accept(occurrence('active')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('self', true)); + const first = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + + const once = harness(); + once.coalescer.restore(first); + await once.coalescer.accept(occurrence('new-quiet', false, false)); + const twice = harness(); + twice.coalescer.restore(structuredClone(once.coalescer.snapshot())); + assert(twice.coalescer.pendingBatchOccurrences().some(o => o.eventId === 'active')); + await twice.coalescer.assemble('agent'); + assert.deepEqual(twice.delivered.map(d => d.id), ['active', 'new-quiet']); + assert.deepEqual(twice.delivered.map(d => d.activation), ['active', 'new-quiet']); +}); diff --git a/test/conversation-self-echo.test.ts b/test/conversation-self-echo.test.ts index 8ab27e5d5..78120987c 100644 --- a/test/conversation-self-echo.test.ts +++ b/test/conversation-self-echo.test.ts @@ -397,3 +397,67 @@ test('a receipted self update never re-evaluates trigger policy using a newer su await f.framework.runUntilIdle(); assert.equal(f.membrane.calls.length, 1); }); + +for (const recovery of ['snapshot', 'receipt bridge'] as const) { + for (const deferred of [false, true]) { + test('inherited self author survives ' + recovery + ' for deferred=' + deferred, async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return true; } } }); + await f.send('push/event', { + ...f.params('first-self', 'self', { channelId: 'chat', key: 'same-author', deferred }), + origin: { authorId: 'self', botUserId: 'self' }, + }); + if (recovery === 'snapshot') await f.framework.stop(); + else await crash(f); + await f.create(); + await f.register(); + await f.send('push/event', { + ...f.params('next-self', 'self update', { channelId: 'chat', key: 'same-author', deferred }), + origin: { botUserId: 'self' }, + }); + assert.equal(filterCalls, 0, 'recovery must preserve the author used by inherited identity'); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + assert.equal(f.membrane.calls.length, 0); + }); +} +} + +test('a published frozen fork batch is settled from its old namespace before self-only recovery can wake', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const originalFork = f.framework.getConversationRouter()!.getBinding('chat')!.agentName; + let release!: (value: unknown) => void; + const started = new Promise(resolve => { + f.renderer(() => { resolve(); return new Promise(r => { release = r; }); }); + }); + await f.send('push/event', { + ...f.params('active', 'older active fallback', { channelId: 'chat', key: 'overlap', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + const run = f.framework.runUntilIdle(); + await started; + await f.send('push/event', { + ...f.params('self-during-render', 'self fallback', { channelId: 'chat', key: 'overlap', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self' }, + }); + // Retain the pre-publication snapshot/receipts, as a crash in the checkpoint + // window would. Context publication itself still goes to the real store. + const internals = f.framework as unknown as { + flushCoalescingSnapshot(): void; + coalescingSaveTimer: ReturnType | null; + }; + if (internals.coalescingSaveTimer) clearTimeout(internals.coalescingSaveTimer); + internals.coalescingSaveTimer = null; + internals.flushCoalescingSnapshot = () => {}; + release({ content: [{ type: 'text', text: 'completed active render' }] }); + await run; + assert(f.context(originalFork).includes('completed active render')); + const calls = f.membrane.calls.length; + await crash(f); + await f.create(); + await f.register(); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'an already-published counterpart cause cannot wake a new fork'); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); +}); From 337e5497a951edc4641c36f3a3842a4c4b449b5a Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 02:50:27 -0400 Subject: [PATCH 06/12] test(routing): distinguish self-echo timers from general gate displacement Tessa independently reproduced qualifying-to-skip debounce displacement on clean main. Felix and Sage explicitly scoped general occurrence-owned EventGate cancellation OUT of issue #47 rather than letting broader supersession wording claim an unrelated architecture repair. Document that a newer counterpart replaces the coalescer cause, while existing EventGate delayed causes have a separate pre-existing cancellation limitation. Add plain and deferred wire controls proving that a self update neither invokes policy nor cancels or extends a genuine counterpart debounce timer. Firing that unchanged timer still wakes and delivers accepted context. Both controls pass after a rebuild (2 / 0). Production code remains identical to 944d436; final full-suite evidence and renewed reviews are reported separately. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 2 +- test/conversation-self-echo.test.ts | 40 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 493d09047..604198ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Releases up to and including 0.7.3 predate this file; for their contents see - **Hosts using conversation forks:** Self-authored channel echoes continue updating an existing fork's context, but cannot create a fork, request inference, or refresh its idle clock (#47). Self identity comes from `chat:from-self` or a nonempty author ID matching the message's `botUserId`; other bots and unknown identities keep normal routing. The veto precedes trigger callbacks so echoes cannot schedule delayed gate wakes. Non-channel pushes and deployments without conversation routing keep their existing behavior. - Coalescer recovery retains interrupted frozen batches alongside newer pending work, including their fallback content, activity causes, and publication targets. Recovered frozen work materializes first, and already-published work is settled against its original fork namespace before any wake is restored. Receipt recovery also preserves resolved author identity. This closes the render-in-flight crash window where a later self update could discard an unconsumed counterpart cause. -- Deferred channel batches retain the latest counterpart activation separately from their latest content. A self update preserves a pending counterpart cause; a later counterpart update replaces it, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Snapshots and receipt recovery preserve the cause, so fresh counterpart activity still counts in mixed batches while historical rendered text cannot renew a self-only batch. Legacy snapshots have only their recorded latest occurrence; an earlier overwritten cause cannot be reconstructed. +- Deferred channel batches retain the latest counterpart activation separately from their latest content. A self update preserves a pending counterpart cause; a later counterpart update replaces the coalescer's cause, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Snapshots and receipt recovery preserve the cause, so fresh counterpart activity still counts in mixed batches while historical rendered text cannot renew a self-only batch. Legacy snapshots have only their recorded latest occurrence; an earlier overwritten cause cannot be reconstructed. Existing EventGate debounce causes are separate: self echoes neither create nor extend those timers, while general counterpart-to-counterpart replacement still has the pre-existing delayed-wake cancellation limitation. ## 0.19.0 — 2026-09-28 diff --git a/test/conversation-self-echo.test.ts b/test/conversation-self-echo.test.ts index 78120987c..69b156d01 100644 --- a/test/conversation-self-echo.test.ts +++ b/test/conversation-self-echo.test.ts @@ -461,3 +461,43 @@ test('a published frozen fork batch is settled from its old namespace before sel assert.equal(f.membrane.calls.length, calls, 'an already-published counterpart cause cannot wake a new fork'); assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); }); + +for (const lane of ['plain', 'deferred'] as const) { + test('a ' + lane + ' self update preserves a genuine counterpart debounce timer and its deadline', async (t) => { + const f = await setup(t, { framework: { gate: { config: { + default: 'skip', policies: [{ name: 'counterpart', match: { tagsAny: ['probe:wake'] }, behavior: { debounce: 5000 } }], + } } } }); + // Establish a fork without a pending wake. + await sendPlain(f, 'start', 'initial counterpart', { tags: [] }); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + const counterpart = { + ...f.params('counterpart', 'genuine counterpart', { channelId: 'chat', key: 'same', deferred: lane === 'deferred' }), + tags: ['probe:wake'], origin: { authorId: 'human' }, + }; + await f.send('push/event', counterpart); + const gate = (f.framework as unknown as { eventGate: { + debounceTimers: Map; events: unknown[] }>; + fireDebounce(name: string): void; + } }).eventGate; + const pending = gate.debounceTimers.get('counterpart')!; + assert(pending, 'the counterpart arms a delayed wake'); + const timer = pending.timer; + const events = [...pending.events]; + await f.send('push/event', { + ...f.params('self', 'self addition', { channelId: 'chat', key: 'same', deferred: lane === 'deferred' }), + tags: ['chat:from-self', 'probe:wake'], origin: { authorId: 'self' }, + }); + assert.equal(gate.debounceTimers.get('counterpart')?.timer, timer, 'self cannot cancel or extend the original timer'); + assert.deepEqual(pending.events, events, 'self creates no delayed gate cause'); + clearTimeout(timer); + gate.fireDebounce('counterpart'); + await f.framework.runUntilIdle(); + assert(f.membrane.calls.length > 0, 'the genuine counterpart wake is preserved'); + if (lane === 'deferred') { + assert.equal(f.renders.length, 1); + assert(f.context(binding.agentName).includes('document_diff')); + } else { + assert(f.context(binding.agentName).includes('self addition')); + } + }); +} From 768c7def1ef00e8a65655a6f1f793734355f043a Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 02:54:55 -0400 Subject: [PATCH 07/12] fix(coalescing): recover work independently for each live audience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wren found that the oldest recovered frozen batch blocked all later work when its fixed recipient no longer existed, or when another live recipient was assembling. A global recovered[0] gate is wrong once distinct batches keep distinct audiences. Select the oldest eligible recovered batch for the assembling recipient. Audit and remove frozen work whose audience vanished or authority was revoked, without widening its audience. Retain batches for another live recipient, and allow a later eligible pending batch to assemble. After materialization or revocation, rearm the remaining independent causes so consuming one batch cannot cancel another recipient’s wake. Three direct regressions distinguish gone, other-live, and unauthorized older audiences. All fail on 337e549. Rebuilt direct recovery and self-echo suites pass 39 / 0. Submission still waits for renewed recovery review and the coordinated full-suite retry. Co-Authored-By: GPT-6 Astra --- src/mcpl/push-coalescer.ts | 54 +++++++++++++++++++++----- test/coalescer-render-recovery.test.ts | 39 +++++++++++++++++-- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 0dda07903..cb1980e3b 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -678,16 +678,46 @@ export class PushCoalescer { return (await this.host.audience(this.activationFor(rendering.batch) ?? rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done, recovered: !!rendering.recovered } : undefined; } - const recovered = !!state.recovered?.length; - const batch = recovered ? state.recovered![0] : state.batch; - if (!batch) return undefined; - const audience = await this.host.audience(this.activationFor(batch) ?? batch.latest); - if (!audience.includes(agentName) || (recovered ? state.recovered?.[0] !== batch : state.batch !== batch) || state.rendering) return undefined; - if (recovered) state.recovered!.shift(); - else state.batch = undefined; + let recovered = false; + let batch: DeferredBatch | undefined; + let pruned = false; + // Preserve order within this recipient's recovered work, not a global + // head-of-line dependency on another (or vanished) recipient's batch. + for (let i = 0; i < (state.recovered?.length ?? 0);) { + const candidate = state.recovered![i]!; + const authorized = this.host.authorized(candidate.latest); + const audience = authorized + ? await this.host.audience(this.activationFor(candidate) ?? candidate.latest) : []; + if (!authorized || audience.length === 0) { + this.host.audit({ kind: authorized ? 'audience-gone' : 'revoked', subject, eventId: candidate.latest.eventId }); + state.recovered!.splice(i, 1); + pruned = true; + continue; + } + if (audience.includes(agentName)) { + batch = candidate; + state.recovered!.splice(i, 1); + recovered = true; + break; + } + i++; + } + if (!batch && state.batch) { + const candidate = state.batch; + const audience = await this.host.audience(this.activationFor(candidate) ?? candidate.latest); + if (audience.includes(agentName) && state.batch === candidate && !state.rendering) { + batch = candidate; + state.batch = undefined; + } + } + if (!batch) { + if (pruned) await this.wakeSubject(subject, state); + return undefined; + } this.host.cancelWake(subject); if (!this.host.authorized(batch.latest)) { this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId }); + await this.wakeSubject(subject, state); return { done: Promise.resolve(), recovered }; } batch.publicationAgent = agentName; @@ -695,7 +725,7 @@ export class PushCoalescer { state.rendering = rendering; const putBack = () => { state.rendering = undefined; - if (recovered) (state.recovered ??= []).unshift(batch); + if (recovered) (state.recovered ??= []).unshift(batch!); else state.batch = batch; }; try { @@ -713,6 +743,7 @@ export class PushCoalescer { if (!this.host.authorized(batch.latest)) { this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId }); state.rendering = undefined; + await this.wakeSubject(subject, state); return { done: Promise.resolve(), recovered }; } rendering.done = this.render(subject, state, rendering, agentName); @@ -771,7 +802,12 @@ export class PushCoalescer { state.occupant = undefined; if (placement) state.audienceAgent = placement.agent; } finally { - if (state.rendering === rendering) state.rendering = undefined; + if (state.rendering === rendering) { + state.rendering = undefined; + // This materialization consumed only its own cause. Other live + // recipients and newer pending batches still own their wakes. + if (!this.suspended) await this.wakeSubject(subject, state); + } } }); } diff --git a/test/coalescer-render-recovery.test.ts b/test/coalescer-render-recovery.test.ts index 7ef3dee67..881b17416 100644 --- a/test/coalescer-render-recovery.test.ts +++ b/test/coalescer-render-recovery.test.ts @@ -21,6 +21,8 @@ function occurrence(id: string, self = false, wake = !self): Occurrence { function harness(published = new Set()) { const delivered: Array<{ id: string; body: string; activation: string | null }> = []; const wakes = new Set(); + const audiences = new Map(); + const unauthorized = new Set(); let saved: CoalescingSnapshot | undefined; const receipts: CoalescingReceiptRecord[] = []; let release: ((value: { content: Array<{ type: 'text'; text: string }> }) => void) | undefined; @@ -44,8 +46,8 @@ function harness(published = new Set()) { if (o.event.wake && !o.event.self) wakes.add(o.eventId); }, cancelWake: () => { wakes.clear(); }, - authorized: () => true, - audience: async () => ['agent'], + authorized: o => !unauthorized.has(o.eventId), + audience: async o => audiences.get(o.eventId) ?? [o.deliverTo ?? 'agent'], render: async (o) => { if (!hold) return { content: [{ type: 'text', text: 'render:' + o.eventId }] }; started(); @@ -59,7 +61,7 @@ function harness(published = new Set()) { wasPublished: (_subject, id) => published.has(id), }); return { - coalescer, delivered, wakes, published, receipts, rendering, + coalescer, delivered, wakes, published, receipts, rendering, audiences, unauthorized, hold: () => { hold = true; }, release: () => { hold = false; release!({ content: [{ type: 'text', text: 'completed active render' }] }); }, saved: () => structuredClone(saved!), @@ -164,3 +166,34 @@ test('a second interruption preserves frozen and pending batches with their sepa assert.deepEqual(twice.delivered.map(d => d.id), ['active', 'new-quiet']); assert.deepEqual(twice.delivered.map(d => d.activation), ['active', 'new-quiet']); }); + +for (const olderAudience of ['gone', 'other-live', 'unauthorized'] as const) { + test('a frozen batch for ' + olderAudience + ' cannot block a newer batch for this recipient', async () => { + const live = harness(); + await live.coalescer.accept({ ...occurrence('old'), deliverTo: 'old-fork' }); + live.hold(); + const run = live.coalescer.assemble('old-fork'); + await live.rendering; + await live.coalescer.accept(occurrence('new')); + const snapshot = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + + const restored = harness(); + restored.audiences.set('old', olderAudience === 'gone' ? [] : ['old-fork']); + restored.audiences.set('new', ['new-fork']); + if (olderAudience === 'unauthorized') restored.unauthorized.add('old'); + restored.coalescer.restore(snapshot); + await restored.coalescer.assemble('new-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new'], 'later eligible work is not head-of-line blocked'); + if (olderAudience === 'other-live') { + assert.deepEqual(restored.coalescer.pendingBatchOccurrences().map(o => o.eventId), ['old']); + assert(restored.wakes.has('old'), 'consuming the newer batch preserves the other recipient cause'); + await restored.coalescer.assemble('old-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new', 'old']); + } else { + assert.deepEqual(restored.coalescer.pendingBatchOccurrences(), [], 'unreachable frozen work is settled without widening audience'); + await restored.coalescer.assemble('old-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new']); + } + }); +} From 6af1b98934a5d16eb72bb7477244309f3c1d2e47 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 03:45:15 -0400 Subject: [PATCH 08/12] fix(coalescing): retain transient recovery work and authorize before waking Greptile found that empty audience conflated a vanished fixed recipient with temporary fork creation failure, and that boot restored activation wakes before checking current peer authority. Five real-wire regressions fail on submitted 768c7de: denied push/channel grants, reassigned binding, delayed registration, and transient spawn failure. Make audience null a temporary resolution failure, distinct from an empty recipient list. Preserve pending/frozen batches on null or resolution exceptions, allow other recipients to progress, and use existing recovery backoff to retry a temporary spawn failure. Expose resumePending under the coalescer lock for readiness events. Make unknown current authority explicit: undefined while transport, policy, awareness barrier, or channel registration is pending; false for denied grants or changed peer binding. Check current authority before audience creation and wake effects. Retain work while readiness is unknown, including readiness loss during render; preserve fallback without repeating a possibly executed render. Re-evaluate on MCPL ready, registration/change, and manifest-policy completion. Keep positive registration tests asynchronous because a protocol response precedes reconciliation and wake completion. Nine added controls cover the five original findings plus autonomous backoff, other-recipient progress under temporary audience/authority conditions, and readiness loss during render. Built direct recovery/self-echo/coalescing suites pass 114 / 0. Independent re-review and the coordinated full compiled suite follow before push. General EventGate debounce-displacement redesign remains outside this PR. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 1 + src/framework.ts | 45 +++++---- src/mcpl/push-coalescer.ts | 95 ++++++++++++++---- test/coalescer-render-recovery.test.ts | 58 ++++++++++- test/conversation-self-echo.test.ts | 129 ++++++++++++++++++++++++- 5 files changed, 288 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 604198ce3..af7c8547f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Releases up to and including 0.7.3 predate this file; for their contents see ### Fixed - **Hosts using conversation forks:** Self-authored channel echoes continue updating an existing fork's context, but cannot create a fork, request inference, or refresh its idle clock (#47). Self identity comes from `chat:from-self` or a nonempty author ID matching the message's `botUserId`; other bots and unknown identities keep normal routing. The veto precedes trigger callbacks so echoes cannot schedule delayed gate wakes. Non-channel pushes and deployments without conversation routing keep their existing behavior. +- Recovery distinguishes a vanished recipient from temporary audience-resolution failure, retaining acknowledged work and retrying transient fork creation with backoff. Restored wakes require current server binding, grants, and channel registration before any fork or turn is created; work waiting for transport/policy/registration readiness resumes at those boundaries. - Coalescer recovery retains interrupted frozen batches alongside newer pending work, including their fallback content, activity causes, and publication targets. Recovered frozen work materializes first, and already-published work is settled against its original fork namespace before any wake is restored. Receipt recovery also preserves resolved author identity. This closes the render-in-flight crash window where a later self update could discard an unconsumed counterpart cause. - Deferred channel batches retain the latest counterpart activation separately from their latest content. A self update preserves a pending counterpart cause; a later counterpart update replaces the coalescer's cause, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Snapshots and receipt recovery preserve the cause, so fresh counterpart activity still counts in mixed batches while historical rendered text cannot renew a self-only batch. Legacy snapshots have only their recorded latest occurrence; an earlier overwritten cause cannot be reconstructed. Existing EventGate debounce causes are separate: self echoes neither create nor extend those timers, while general counterpart-to-counterpart replacement still has the pre-existing delayed-wake cancellation limitation. diff --git a/src/framework.ts b/src/framework.ts index 6367f3c66..b47cf3303 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7476,9 +7476,11 @@ export class AgentFramework { // Restore independent batch causes in order. An older frozen counterpart // cannot be cancelled by a newer quiet batch on the same subject, and two // causes must not race to create the same conversation fork. - void this.admitCoalesced(async () => { - for (const occ of coalescer.pendingBatchOccurrences()) await this.wakeForCoalescedBatch(occ, true); - }).catch(err => console.error('[coalescing] could not restore pending wakes:', err)); + this.resumeCoalescedWork(); + } + + private resumeCoalescedWork(): void { + void this.pushCoalescer?.resumePending().catch(err => console.error('[coalescing] could not resume pending work:', err)); } /** @@ -7634,13 +7636,13 @@ export class AgentFramework { } } - private coalescedAuthorized(occ: CoalescedOccurrence): boolean { + private coalescedAuthorized(occ: CoalescedOccurrence): boolean | undefined { + // No current peer identity means this obligation cannot be transferred. + if (!this.mcplServerConfigs.has(occ.serverId) || occ.binding !== this.coalescingBinding(occ.serverId)) return false; const server = this.mcplServerRegistry?.getServer(occ.serverId); - if (!server?.isConnected || !server.policyEstablished) return false; - // §3.2: work admitted under another binding (the id since reassigned to a - // different endpoint) never acquires the replacement peer's authority — - // and its private notice data is never sent to that peer. - if (occ.binding !== this.coalescingBinding(occ.serverId)) return false; + // Startup/reconnect is not revocation. Keep work until current authority + // is known, and re-evaluate at readiness/registration boundaries. + if (!server?.isConnected || !server.policyEstablished || this.discordAwarenessBarrier) return undefined; if (occ.event.lane === 'push') { if (!server.grant.has('pushEvents')) return false; try { this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet); } catch { return false; } @@ -7648,8 +7650,9 @@ export class AgentFramework { if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') { // §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane. if (!server.grant.has('channels.incoming')) return false; - if (occ.event.lane === 'push') return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false; - return !!this.channelRegistry?.getDescriptor(occ.scope.id); + // A channel can register after policy establishment. Its absence is + // not authority to wake and not proof acknowledged work was revoked. + return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ? true : undefined; } return true; } @@ -7662,7 +7665,7 @@ export class AgentFramework { return isSelfAuthoredMessage(occ.tags, authorId, metadata); } - private async coalescedAudience(occ: CoalescedOccurrence): Promise { + private async coalescedAudience(occ: CoalescedOccurrence): Promise { if (occ.deliverTo) return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : []; const explicit = occ.event.event.targetAgents; if (explicit) return explicit.filter((n) => this.agents.has(n)); @@ -7687,7 +7690,7 @@ export class AgentFramework { return [decision.agentName]; } catch (err) { console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err); - return []; + return null; // temporary resolution failure, not a vanished fixed audience } } return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName); @@ -7760,17 +7763,21 @@ export class AgentFramework { return placement; } - private async wakeForCoalescedBatch(occ: CoalescedOccurrence, preserveExisting = false): Promise { + private async wakeForCoalescedBatch(occ: CoalescedOccurrence, preserveExisting = false): Promise { const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); if (!preserveExisting) this.cancelCoalescedWakes(subject); if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return; + if (this.coalescedAuthorized(occ) !== true) return; if (this.conversationRouter && occ.scope.kind === 'channel' && this.isSelfConversationOccurrence(occ)) return; const origin = occ.event.event.origin; // The wake carries the batch's channel so the turn freezes its reply // locus there (the materialized delivery at assembly wakes nobody). const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId; const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined); - for (const agentName of await this.coalescedAudience(occ)) { + const audience = await this.coalescedAudience(occ); + if (audience === null) return false; + if (this.coalescedAuthorized(occ) !== true) return; + for (const agentName of audience) { this.pendingRequests.push({ agentName, reason: 'mcpl:push-event', @@ -13086,6 +13093,7 @@ export class AgentFramework { } connection.ready(); } + if (this.discordAwarenessBarrier === null) this.resumeCoalescedWork(); return this.discordAwarenessBarrier === null; } @@ -13361,6 +13369,7 @@ export class AgentFramework { impacts, }); this.handleToolsListChanged(connection.id); + this.resumeCoalescedWork(); } finally { st.inFlight = false; st.lastCompletedAt = Date.now(); @@ -13704,7 +13713,8 @@ export class AgentFramework { params: ChannelsRegisterParams, responder?: { respond: (result: unknown) => void }, ) => { - await this.channelRegistry?.handleRegister(connection.id, params, responder as never); + try { await this.channelRegistry?.handleRegister(connection.id, params, responder as never); } + finally { this.resumeCoalescedWork(); } }); // Handle channel changes (Step 7) — §14.5 dual-mode: the responder is @@ -13715,7 +13725,8 @@ export class AgentFramework { params: ChannelsChangedParams, responder?: { respond: (result: unknown) => void }, ) => { - await this.channelRegistry?.handleChanged(connection.id, params, responder as never); + try { await this.channelRegistry?.handleChanged(connection.id, params, responder as never); } + finally { this.resumeCoalescedWork(); } }); // §6.6/§12: requests that previously had no handler and hung forever diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index cb1980e3b..4a3188dcb 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -183,13 +183,16 @@ export interface CoalescerHost { * pending activity/wake cause. Re-evaluated for restored legacy batches. */ isPassive?(occurrence: CoalescedOccurrence): boolean; /** Queue a wake for a batch that has no model-visible content yet. */ - wakeForBatch(occurrence: CoalescedOccurrence, preserveExisting?: boolean): Promise; + /** false means a temporary audience failure; keep work and retry with backoff. */ + wakeForBatch(occurrence: CoalescedOccurrence, preserveExisting?: boolean): Promise; /** Withdraw unstarted wakes whose sole cause is this subject. */ cancelWake(subject: string): void; - /** The batch's audience still holds the authority it was admitted under. */ - authorized(occurrence: CoalescedOccurrence): boolean; - /** Agents that read this occurrence's delivery target (may spawn a fork). */ - audience(occurrence: CoalescedOccurrence): Promise; + /** true: currently authorized; false: denied/reassigned; undefined: current + * authority is not yet known (connection, policy, or registration pending). */ + authorized(occurrence: CoalescedOccurrence): boolean | undefined; + /** [] means no recipient exists; null means resolution failed temporarily. + * May spawn a fork, so call only after current authorization succeeds. */ + audience(occurrence: CoalescedOccurrence): Promise; render(occurrence: CoalescedOccurrence, params: PushRenderParams): Promise; audit(record: Record): void; /** Persist (throttled by the host); the thunk builds the snapshot lazily. */ @@ -475,9 +478,12 @@ export class PushCoalescer { if (this.recovery.get(subject) === entry) entry.timer = undefined; if (this.suspended || this.subjects.get(subject) !== state || (!state.batch && !state.recovered?.length) || state.rendering) return; // Authority and wake policy are the host's, re-evaluated at fire time. - if (!this.workBatches(state).some(b => this.host.authorized(b.latest))) return; + if (!this.workBatches(state).some(b => this.host.authorized(b.latest) === true)) return; this.host.audit({ kind: 'recovery-wake', subject, eventId: this.workBatches(state)[0]?.latest.eventId, attempt, delayMs: delay }); - void this.wakeSubject(subject, state).catch(() => { /* audited by the host */ }); + void this.locked(async () => { + if (this.suspended || this.subjects.get(subject) !== state) return; + await this.wakeSubject(subject, state); + }).catch(() => { /* audited by the host */ }); }, delay); (entry.timer as { unref?: () => void }).unref?.(); this.recovery.set(subject, entry); @@ -600,13 +606,41 @@ export class PushCoalescer { }; } + /** Re-evaluate retained work when current transport/policy/channel state changes. */ + resumePending(): Promise { + return this.locked(async () => { + if (this.suspended) return; + for (const [subject, state] of this.subjects) { + if (this.workBatches(state).length) await this.wakeSubject(subject, state); + } + }); + } + + private async resolveAudience(subject: string, state: SubjectState, batch: DeferredBatch): Promise { + try { + const audience = await this.host.audience(this.activationFor(batch) ?? batch.latest); + if (audience !== null) return audience; + } catch (error) { + this.host.audit({ kind: 'audience-unavailable', subject, eventId: batch.latest.eventId, error: String(error) }); + } + this.scheduleRecovery(subject, state); + return null; + } + private async wakeSubject(subject: string, state: SubjectState): Promise { this.host.cancelWake(subject); for (const batch of this.workBatches(state)) { const activation = this.activationFor(batch); // Different frozen/pending batches have independent causes. A quiet // newer batch must not cancel an older, still-unconsumed obligation. - if (activation) await this.host.wakeForBatch(activation, true); + if (!activation || this.host.authorized(batch.latest) !== true + || this.host.authorized(activation) !== true) continue; + try { + if (await this.host.wakeForBatch(activation, true) === false) this.scheduleRecovery(subject, state); + } catch (error) { + this.host.audit({ kind: 'audience-unavailable', subject, eventId: batch.latest.eventId, error: String(error) }); + this.scheduleRecovery(subject, state); + } } } @@ -675,7 +709,8 @@ export class PushCoalescer { if (!state || this.suspended) return undefined; if (state.rendering) { const rendering = state.rendering; - return (await this.host.audience(this.activationFor(rendering.batch) ?? rendering.batch.latest)).includes(agentName) && state.rendering === rendering + if (this.host.authorized(rendering.batch.latest) !== true) return undefined; + return (await this.resolveAudience(subject, state, rendering.batch))?.includes(agentName) && state.rendering === rendering ? { done: rendering.done, recovered: !!rendering.recovered } : undefined; } let recovered = false; @@ -686,9 +721,10 @@ export class PushCoalescer { for (let i = 0; i < (state.recovered?.length ?? 0);) { const candidate = state.recovered![i]!; const authorized = this.host.authorized(candidate.latest); - const audience = authorized - ? await this.host.audience(this.activationFor(candidate) ?? candidate.latest) : []; - if (!authorized || audience.length === 0) { + if (authorized === undefined) { i++; continue; } + const audience = authorized ? await this.resolveAudience(subject, state, candidate) : []; + if (audience === null) { i++; continue; } + if (authorized === false || audience.length === 0) { this.host.audit({ kind: authorized ? 'audience-gone' : 'revoked', subject, eventId: candidate.latest.eventId }); state.recovered!.splice(i, 1); pruned = true; @@ -704,10 +740,17 @@ export class PushCoalescer { } if (!batch && state.batch) { const candidate = state.batch; - const audience = await this.host.audience(this.activationFor(candidate) ?? candidate.latest); - if (audience.includes(agentName) && state.batch === candidate && !state.rendering) { - batch = candidate; + const authorized = this.host.authorized(candidate.latest); + if (authorized === false) { + this.host.audit({ kind: 'revoked', subject, eventId: candidate.latest.eventId }); state.batch = undefined; + pruned = true; + } else if (authorized === true) { + const audience = await this.resolveAudience(subject, state, candidate); + if (audience?.includes(agentName) && state.batch === candidate && !state.rendering) { + batch = candidate; + state.batch = undefined; + } } } if (!batch) { @@ -715,7 +758,13 @@ export class PushCoalescer { return undefined; } this.host.cancelWake(subject); - if (!this.host.authorized(batch.latest)) { + const authority = this.host.authorized(batch.latest); + if (authority === undefined) { + if (recovered) (state.recovered ??= []).unshift(batch); + else state.batch = batch; + return undefined; + } + if (authority === false) { this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId }); await this.wakeSubject(subject, state); return { done: Promise.resolve(), recovered }; @@ -740,7 +789,9 @@ export class PushCoalescer { } this.clearRecovery(subject); if (this.suspended) { putBack(); return undefined; } - if (!this.host.authorized(batch.latest)) { + const dispatchAuthority = this.host.authorized(batch.latest); + if (dispatchAuthority === undefined) { putBack(); return undefined; } + if (dispatchAuthority === false) { this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId }); state.rendering = undefined; await this.wakeSubject(subject, state); @@ -785,7 +836,15 @@ export class PushCoalescer { try { if (rendering.cancelled || state.rendering !== rendering || this.suspended) return; // Rule 4: authority is re-checked at response. - if (!this.host.authorized(occurrence)) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; } + const authority = this.host.authorized(occurrence); + if (authority === undefined) { + // Transport/policy readiness may return. Keep the accepted fallback + // as interrupted work; never repeat a render that may have executed. + batch.noRender = true; + (state.recovered ??= []).unshift(batch); + return; + } + if (authority === false) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; } this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty: content.length === 0 }); if (!content.length) return; // §5.2: nothing happened const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor, this.activationFor(batch)); diff --git a/test/coalescer-render-recovery.test.ts b/test/coalescer-render-recovery.test.ts index 881b17416..218496afd 100644 --- a/test/coalescer-render-recovery.test.ts +++ b/test/coalescer-render-recovery.test.ts @@ -21,7 +21,9 @@ function occurrence(id: string, self = false, wake = !self): Occurrence { function harness(published = new Set()) { const delivered: Array<{ id: string; body: string; activation: string | null }> = []; const wakes = new Set(); - const audiences = new Map(); + const audiences = new Map(); + const waitingAuthority = new Set(); + const audienceCalls: string[] = []; const unauthorized = new Set(); let saved: CoalescingSnapshot | undefined; const receipts: CoalescingReceiptRecord[] = []; @@ -46,8 +48,11 @@ function harness(published = new Set()) { if (o.event.wake && !o.event.self) wakes.add(o.eventId); }, cancelWake: () => { wakes.clear(); }, - authorized: o => !unauthorized.has(o.eventId), - audience: async o => audiences.get(o.eventId) ?? [o.deliverTo ?? 'agent'], + authorized: o => waitingAuthority.has(o.eventId) ? undefined : !unauthorized.has(o.eventId), + audience: async o => { + audienceCalls.push(o.eventId); + return audiences.has(o.eventId) ? audiences.get(o.eventId)! : [o.deliverTo ?? 'agent']; + }, render: async (o) => { if (!hold) return { content: [{ type: 'text', text: 'render:' + o.eventId }] }; started(); @@ -61,7 +66,7 @@ function harness(published = new Set()) { wasPublished: (_subject, id) => published.has(id), }); return { - coalescer, delivered, wakes, published, receipts, rendering, audiences, unauthorized, + coalescer, delivered, wakes, published, receipts, rendering, audiences, unauthorized, waitingAuthority, audienceCalls, hold: () => { hold = true; }, release: () => { hold = false; release!({ content: [{ type: 'text', text: 'completed active render' }] }); }, saved: () => structuredClone(saved!), @@ -197,3 +202,48 @@ for (const olderAudience of ['gone', 'other-live', 'unauthorized'] as const) { } }); } + +for (const obstruction of ['temporary audience', 'authority not ready'] as const) { + test(obstruction + ' preserves frozen work while a different recipient progresses', async (t) => { + const live = harness(); + await live.coalescer.accept({ ...occurrence('old'), deliverTo: 'old-fork' }); + live.hold(); + const run = live.coalescer.assemble('old-fork'); + await live.rendering; + await live.coalescer.accept({ ...occurrence('new'), deliverTo: 'new-fork' }); + const snapshot = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + const restored = harness(); + t.after(() => restored.coalescer.suspend()); + restored.coalescer.restore(snapshot); + if (obstruction === 'temporary audience') restored.audiences.set('old', null); + else restored.waitingAuthority.add('old'); + await restored.coalescer.assemble('new-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new']); + assert.deepEqual(restored.coalescer.pendingBatchOccurrences().map(o => o.eventId), ['old']); + if (obstruction === 'authority not ready') assert(!restored.audienceCalls.includes('old'), 'no audience effects before current authority'); + restored.audiences.delete('old'); + restored.waitingAuthority.delete('old'); + await restored.coalescer.assemble('old-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new', 'old']); + }); +} + +test('authority becoming unknown during render retains fallback until readiness returns', async (t) => { + const f = harness(); + t.after(() => f.coalescer.suspend()); + await f.coalescer.accept(occurrence('active')); + f.hold(); + const run = f.coalescer.assemble('agent'); + await f.rendering; + f.waitingAuthority.add('active'); + f.release(); await run; + assert.equal(f.delivered.length, 0, 'unready authority cannot publish'); + assert.equal(f.coalescer.pendingBatches(), 1, 'temporary readiness loss is not revocation'); + f.waitingAuthority.delete('active'); + await f.coalescer.resumePending(); + assert(f.wakes.has('active')); + await f.coalescer.assemble('agent'); + assert.equal(f.delivered.length, 1); + assert(f.delivered[0]!.body.includes('fallback:active'), 'do not repeat the interrupted render'); +}); diff --git a/test/conversation-self-echo.test.ts b/test/conversation-self-echo.test.ts index 69b156d01..c7ee466df 100644 --- a/test/conversation-self-echo.test.ts +++ b/test/conversation-self-echo.test.ts @@ -2,7 +2,7 @@ import { test, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; import type { FrameworkConfig } from '../src/types/framework.js'; -import { fixture, crash } from './helpers/coalescing-fixture.js'; +import { fixture, crash, eventually } from './helpers/coalescing-fixture.js'; const conversations = { templateAgent: 'agent', @@ -224,6 +224,7 @@ for (const recovery of ['snapshot', 'receipt bridge'] as const) { else await crash(f); await f.create(); await f.register(); + await eventually(() => !!f.framework.getConversationRouter()!.getBinding('chat'), 'mixed-batch recovery after registration'); await f.framework.runUntilIdle(); assert.equal(f.membrane.calls.length, calls + 1, 'restart recovers the counterpart cause, not the last self verdict'); assert(f.framework.getConversationRouter()!.getBinding('chat')); @@ -501,3 +502,129 @@ for (const lane of ['plain', 'deferred'] as const) { } }); } + +for (const denial of ['push grant', 'channel grant', 'binding'] as const) { + test('recovered counterpart work cannot spawn or wake before current ' + denial + ' authorization', async (t) => { + const f = await setup(t); + const params = { + ...f.params('active', 'authorized original', { channelId: 'chat', key: 'recovery-authority', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }; + await f.send('push/event', params); + await f.framework.stop(); + const calls = f.membrane.calls.length; + if (denial === 'push grant') await f.create(false); + else { + // A restored snapshot bound to a different peer must not transfer its wake. + if (denial === 'binding') { + const { JsStore } = await import('@animalabs/chronicle'); + const { join } = await import('node:path'); + const store = JsStore.openOrCreate({ path: join(f.dir, 'store') }); + const snapshot = store.getStateJson('mcpl/coalescing') as { subjects: Array<{ batch?: { latest: { binding: string } } }> }; + for (const s of snapshot.subjects) if (s.batch) s.batch.latest.binding = 'a different peer'; + store.setStateJson('mcpl/coalescing', snapshot); + store.close(); + } + await f.create(); + } + if (denial === 'channel grant') { + const { CapabilityGrant } = await import('../src/mcpl/capability-grant.js'); + const connection = (f.framework as unknown as { mcplServerRegistry: { getServer(id: string): { establishGrant(grant: unknown): void } } }).mcplServerRegistry.getServer('editor'); + connection.establishGrant(new CapabilityGrant(new Set(['pushEvents']), [])); + } + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined, 'current authority must precede audience creation'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'unapproved content cannot cause even an empty turn'); + assert.equal(f.renders.length, 0); + }); +} + +test('valid recovered work waits for channel registration, then wakes once', async (t) => { + const f = await setup(t); + await f.send('push/event', { + ...f.params('active', 'wait for registration', { channelId: 'chat', key: 'recovery-authority', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.framework.stop(); + await f.create(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + f.framework.nudgeAgent('agent', 'unrelated startup work'); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined, 'unrelated assembly cannot spawn or discard unready work'); + await f.register(); + await eventually(() => !!f.framework.getConversationRouter()!.getBinding('chat'), 'registration recovery wake'); + await f.framework.runUntilIdle(); + const binding = f.framework.getConversationRouter()!.getBinding('chat'); + assert(binding, 'registration resumes the still-pending work'); + assert(f.context(binding.agentName).includes('document_diff')); + assert.equal(f.renders.length, 1); + const calls = f.membrane.calls.length; + await f.register(); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'repeated readiness creates no duplicate turn'); +}); + +test('a transient fork creation failure preserves acknowledged recovered work for retry', async (t) => { + const f = await setup(t); + await f.send('push/event', { + ...f.params('active', 'recoverable fallback', { channelId: 'chat', key: 'spawn-failure', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.framework.stop(); + await f.create(); + const router = f.framework.getConversationRouter()!; + // The unfixed recovery may already have spawned; isolate the subsequent + // audience-resolution attempt rather than relying on that separate defect. + router.unbind('chat'); + const internals = f.framework as unknown as { + createConversationAgent(name: string, channelId: string): Promise; + pushCoalescer: { snapshot(): unknown; restore(snapshot: unknown): void; pendingBatches(): number; options: { recoveryBackoffMs?: number } }; + pendingRequests: unknown[]; + }; + internals.pendingRequests.length = 0; + const snapshot = internals.pushCoalescer.snapshot() as { subjects: Array<{ batch?: { rendering?: boolean } }> }; + for (const s of snapshot.subjects) if (s.batch) s.batch.rendering = true; + internals.pushCoalescer.restore(snapshot); + internals.pushCoalescer.options.recoveryBackoffMs = 60_000; + const original = internals.createConversationAgent; + internals.createConversationAgent = async () => { throw new Error('transient spawn failure'); }; + await f.register(); + f.framework.nudgeAgent('agent', 'unrelated turn'); + await f.framework.runUntilIdle(); + assert(internals.pushCoalescer.pendingBatches() > 0, 'failed creation is not permanent audience disappearance'); + internals.createConversationAgent = original; + await f.register(); + await eventually(() => !!router.getBinding('chat'), 'retried audience resolution'); + await f.framework.runUntilIdle(); + const binding = router.getBinding('chat'); + assert(binding); + assert(f.context(binding.agentName).includes('recoverable fallback')); +}); + +test('a transient recovered spawn failure is retried by backoff without another message', async (t) => { + const f = await setup(t); + await f.send('push/event', { + ...f.params('active', 'retry fallback', { channelId: 'chat', key: 'spawn-retry', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.framework.stop(); + await f.create(); + const internals = f.framework as unknown as { + createConversationAgent(name: string, channelId: string): Promise; + pushCoalescer: { options: { recoveryBackoffMs?: number } }; + }; + const original = internals.createConversationAgent; + let attempts = 0; + internals.createConversationAgent = async function (name, channel) { + if (++attempts === 1) throw new Error('one transient creation failure'); + return original.call(this, name, channel); + }; + internals.pushCoalescer.options.recoveryBackoffMs = 20; + await f.register(); + await eventually(() => !!f.framework.getConversationRouter()!.getBinding('chat'), 'autonomous audience retry'); + await f.framework.runUntilIdle(); + assert(attempts >= 2); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + assert(f.context(binding.agentName).includes('document_diff')); + assert.equal(f.membrane.calls.length, 1); +}); From c7da9b4e090faa4b92c878800312d22414e08295 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 14:11:32 -0400 Subject: [PATCH 09/12] docs: move PR release notes to a unique changelog fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream reviewer identified conflicts among concurrent PRs that edit Unreleased directly. Felix explicitly authorized this unique fragment in Commons message 19989. Move only this PR’s own bullets verbatim, preserving existing release history and the reviewed runtime/test tree. The fragment satisfies the added-file changelog check; integration and release-fold validation follow before push. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 4 ---- changelog.d/209-pure-routing-query.breaking.md | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 changelog.d/209-pure-routing-query.breaking.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf0cd586..6b9a30b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,6 @@ Releases up to and including 0.7.3 predate this file; for their contents see ## Unreleased -### Breaking - -- **Direct ConversationRouter callers:** `route()` is now a pure query and no longer refreshes idle activity (#46). After successfully delivering a message to the bound fork, call `touch(channelId)`; deterministic clocks move from `route({ now })` to `touch(channelId, now)`. Framework-managed delivery performs this step automatically, including ambient messages and coalesced fixed-audience delivery to the current binding. Queries, failed writes, and delivery to an older engagement leave the current binding's clock unchanged. Bind rules, trigger rules, generation counters, and fork names are unchanged. No store migration or sibling dependency upgrade is required. - ## 0.19.0 — 2026-09-28 ### Added diff --git a/changelog.d/209-pure-routing-query.breaking.md b/changelog.d/209-pure-routing-query.breaking.md new file mode 100644 index 000000000..93027b4db --- /dev/null +++ b/changelog.d/209-pure-routing-query.breaking.md @@ -0,0 +1 @@ +- **Direct ConversationRouter callers:** `route()` is now a pure query and no longer refreshes idle activity (#46). After successfully delivering a message to the bound fork, call `touch(channelId)`; deterministic clocks move from `route({ now })` to `touch(channelId, now)`. Framework-managed delivery performs this step automatically, including ambient messages and coalesced fixed-audience delivery to the current binding. Queries, failed writes, and delivery to an older engagement leave the current binding's clock unchanged. Bind rules, trigger rules, generation counters, and fork names are unchanged. No store migration or sibling dependency upgrade is required. From c2299c8f56ab8f377e230fd9b40c5f429437d849 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 14:11:33 -0400 Subject: [PATCH 10/12] docs: move PR release notes to a unique changelog fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream reviewer identified conflicts among concurrent PRs that edit Unreleased directly. Felix explicitly authorized this unique fragment in Commons message 19989. Move only this PR’s own bullets verbatim, preserving existing release history and the reviewed runtime/test tree. The fragment satisfies the added-file changelog check; integration and release-fold validation follow before push. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 7 ------- changelog.d/212-passive-self-echoes.fixed.md | 4 ++++ 2 files changed, 4 insertions(+), 7 deletions(-) create mode 100644 changelog.d/212-passive-self-echoes.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index af7c8547f..1cf0cd586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,6 @@ Releases up to and including 0.7.3 predate this file; for their contents see - **Direct ConversationRouter callers:** `route()` is now a pure query and no longer refreshes idle activity (#46). After successfully delivering a message to the bound fork, call `touch(channelId)`; deterministic clocks move from `route({ now })` to `touch(channelId, now)`. Framework-managed delivery performs this step automatically, including ambient messages and coalesced fixed-audience delivery to the current binding. Queries, failed writes, and delivery to an older engagement leave the current binding's clock unchanged. Bind rules, trigger rules, generation counters, and fork names are unchanged. No store migration or sibling dependency upgrade is required. -### Fixed - -- **Hosts using conversation forks:** Self-authored channel echoes continue updating an existing fork's context, but cannot create a fork, request inference, or refresh its idle clock (#47). Self identity comes from `chat:from-self` or a nonempty author ID matching the message's `botUserId`; other bots and unknown identities keep normal routing. The veto precedes trigger callbacks so echoes cannot schedule delayed gate wakes. Non-channel pushes and deployments without conversation routing keep their existing behavior. -- Recovery distinguishes a vanished recipient from temporary audience-resolution failure, retaining acknowledged work and retrying transient fork creation with backoff. Restored wakes require current server binding, grants, and channel registration before any fork or turn is created; work waiting for transport/policy/registration readiness resumes at those boundaries. -- Coalescer recovery retains interrupted frozen batches alongside newer pending work, including their fallback content, activity causes, and publication targets. Recovered frozen work materializes first, and already-published work is settled against its original fork namespace before any wake is restored. Receipt recovery also preserves resolved author identity. This closes the render-in-flight crash window where a later self update could discard an unconsumed counterpart cause. -- Deferred channel batches retain the latest counterpart activation separately from their latest content. A self update preserves a pending counterpart cause; a later counterpart update replaces the coalescer's cause, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Snapshots and receipt recovery preserve the cause, so fresh counterpart activity still counts in mixed batches while historical rendered text cannot renew a self-only batch. Legacy snapshots have only their recorded latest occurrence; an earlier overwritten cause cannot be reconstructed. Existing EventGate debounce causes are separate: self echoes neither create nor extend those timers, while general counterpart-to-counterpart replacement still has the pre-existing delayed-wake cancellation limitation. - ## 0.19.0 — 2026-09-28 ### Added diff --git a/changelog.d/212-passive-self-echoes.fixed.md b/changelog.d/212-passive-self-echoes.fixed.md new file mode 100644 index 000000000..835930e11 --- /dev/null +++ b/changelog.d/212-passive-self-echoes.fixed.md @@ -0,0 +1,4 @@ +- **Hosts using conversation forks:** Self-authored channel echoes continue updating an existing fork's context, but cannot create a fork, request inference, or refresh its idle clock (#47). Self identity comes from `chat:from-self` or a nonempty author ID matching the message's `botUserId`; other bots and unknown identities keep normal routing. The veto precedes trigger callbacks so echoes cannot schedule delayed gate wakes. Non-channel pushes and deployments without conversation routing keep their existing behavior. +- Recovery distinguishes a vanished recipient from temporary audience-resolution failure, retaining acknowledged work and retrying transient fork creation with backoff. Restored wakes require current server binding, grants, and channel registration before any fork or turn is created; work waiting for transport/policy/registration readiness resumes at those boundaries. +- Coalescer recovery retains interrupted frozen batches alongside newer pending work, including their fallback content, activity causes, and publication targets. Recovered frozen work materializes first, and already-published work is settled against its original fork namespace before any wake is restored. Receipt recovery also preserves resolved author identity. This closes the render-in-flight crash window where a later self update could discard an unconsumed counterpart cause. +- Deferred channel batches retain the latest counterpart activation separately from their latest content. A self update preserves a pending counterpart cause; a later counterpart update replaces the coalescer's cause, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Snapshots and receipt recovery preserve the cause, so fresh counterpart activity still counts in mixed batches while historical rendered text cannot renew a self-only batch. Legacy snapshots have only their recorded latest occurrence; an earlier overwritten cause cannot be reconstructed. Existing EventGate debounce causes are separate: self echoes neither create nor extend those timers, while general counterpart-to-counterpart replacement still has the pre-existing delayed-wake cancellation limitation. From 32da286d8cbee8e21d113e47e47c36532c094fc9 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 14:23:50 -0400 Subject: [PATCH 11/12] fix(coalescing): preserve batch role through fallback-only recovery Weft reproduced a second crash reversing r1/r2: a receipt-restored pending batch uses noRender=true to avoid repeating a possibly executed render, but storeBatch serialized that policy as rendering=true. Restore then promoted the newer pending r2 ahead of the older frozen r1. Store fallback policy separately from frozen role, keeping explicit rendering only for frozen work. Weft also reproduced a mixed batch whose counterpart activation survives but whose latest content is self-authored losing its binding during render. Permit retained counterpart activity through the unbound-self guard, preserving accepted materialization while self-only echoes remain unable to spawn. Add an explicit comment that server-owned self classification grants no capability. Two crash-ladder regressions cover a second crash before assembly and during recovery commit; a real-wire regression removes the binding during a mixed render. All three fail on refreshed pre-fix 0c42160. Rebuilt recovery/self-echo/silent-heartbeat tests pass 56 / 0; diff check clean. Independent role/order and routing review, then the coordinated full suite, follow before push. This commit includes the already-authorized fragment update and preserves the current-main/parent refresh ancestry. Co-Authored-By: GPT-6 Astra --- changelog.d/212-passive-self-echoes.fixed.md | 2 +- src/framework.ts | 9 ++-- src/mcpl/push-coalescer.ts | 8 +++- test/coalescer-render-recovery.test.ts | 47 +++++++++++++++++++- test/conversation-self-echo.test.ts | 25 +++++++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/changelog.d/212-passive-self-echoes.fixed.md b/changelog.d/212-passive-self-echoes.fixed.md index 835930e11..fe427a8f5 100644 --- a/changelog.d/212-passive-self-echoes.fixed.md +++ b/changelog.d/212-passive-self-echoes.fixed.md @@ -1,4 +1,4 @@ - **Hosts using conversation forks:** Self-authored channel echoes continue updating an existing fork's context, but cannot create a fork, request inference, or refresh its idle clock (#47). Self identity comes from `chat:from-self` or a nonempty author ID matching the message's `botUserId`; other bots and unknown identities keep normal routing. The veto precedes trigger callbacks so echoes cannot schedule delayed gate wakes. Non-channel pushes and deployments without conversation routing keep their existing behavior. - Recovery distinguishes a vanished recipient from temporary audience-resolution failure, retaining acknowledged work and retrying transient fork creation with backoff. Restored wakes require current server binding, grants, and channel registration before any fork or turn is created; work waiting for transport/policy/registration readiness resumes at those boundaries. -- Coalescer recovery retains interrupted frozen batches alongside newer pending work, including their fallback content, activity causes, and publication targets. Recovered frozen work materializes first, and already-published work is settled against its original fork namespace before any wake is restored. Receipt recovery also preserves resolved author identity. This closes the render-in-flight crash window where a later self update could discard an unconsumed counterpart cause. +- Coalescer recovery retains interrupted frozen batches alongside newer pending work, including their fallback content, activity causes, and publication targets. Recovered frozen work materializes first, and already-published work is settled against its original fork namespace before any wake is restored. Receipt recovery also preserves resolved author identity. Stored fallback-only delivery policy is separate from frozen-batch role, so repeated crashes preserve survivor order. A mixed batch's retained counterpart activation permits delivery if its binding disappears during rendering. This closes the render-in-flight crash window where a later self update could discard an unconsumed counterpart cause. - Deferred channel batches retain the latest counterpart activation separately from their latest content. A self update preserves a pending counterpart cause; a later counterpart update replaces the coalescer's cause, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Snapshots and receipt recovery preserve the cause, so fresh counterpart activity still counts in mixed batches while historical rendered text cannot renew a self-only batch. Legacy snapshots have only their recorded latest occurrence; an earlier overwritten cause cannot be reconstructed. Existing EventGate debounce causes are separate: self echoes neither create nor extend those timers, while general counterpart-to-counterpart replacement still has the pre-existing delayed-wake cancellation limitation. diff --git a/src/framework.ts b/src/framework.ts index 32137f7bd..c33424e70 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -267,7 +267,9 @@ const isAddressedMessage = ( _metadata?: Record, ): boolean => Array.isArray(tags) && tags.includes('chat:addressed'); -/** Identity, not automation: another bot is still a counterpart. */ +/** Identity, not automation: another bot is still a counterpart. These + * server-supplied fields classify traffic the admitted server already owns; + * they neither grant capabilities nor authenticate a separate principal. */ const isSelfAuthoredMessage = ( tags: unknown, authorId: unknown, @@ -7051,8 +7053,9 @@ export class AgentFramework { ): Promise { const router = this.conversationRouter!; const self = isSelfAuthoredMessage(event.tags, event.author.id, event.metadata); - // Echoes can update an existing engagement's context, but cannot start one. - if (self && !router.getBinding(event.channelId)) return; + // A self-only echo cannot start an engagement. A rendered mixed batch + // may still carry genuine counterpart activation after its binding expired. + if (self && !event.conversationActivity && !router.getBinding(event.channelId)) return; const descriptor = this.channelRegistry?.getDescriptor(event.channelId); const decision = router.route({ diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 4a3188dcb..5ae166d84 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -107,7 +107,10 @@ interface StoredDeferredBatch { activation?: CoalescedOccurrence | null; notices: DeferredNotice[]; dropped: number; + /** Role: this was frozen, not merely awaiting fallback-only delivery. */ rendering?: boolean; + /** Delivery policy independent of role/order: never repeat a possible render. */ + fallback?: boolean; publicationAgent?: string; } @@ -588,16 +591,17 @@ export class PushCoalescer { return [...(state.rendering ? [state.rendering.batch] : []), ...(state.recovered ?? [])]; } - private storeBatch(batch: DeferredBatch, rendering = !!batch.noRender): StoredDeferredBatch { + private storeBatch(batch: DeferredBatch, rendering = false): StoredDeferredBatch { return { latest: batch.latest as CoalescedOccurrence, notices: batch.notices, dropped: batch.dropped, ...(batch.activation !== undefined ? { activation: batch.activation as CoalescedOccurrence | null } : {}), ...(batch.publicationAgent ? { publicationAgent: batch.publicationAgent } : {}), + ...(batch.noRender ? { fallback: true } : {}), ...(rendering ? { rendering: true } : {}), }; } - private restoreBatch(batch: StoredDeferredBatch, noRender = !!batch.rendering): DeferredBatch { + private restoreBatch(batch: StoredDeferredBatch, noRender = !!(batch.rendering || batch.fallback)): DeferredBatch { return { latest: batch.latest as CoalescedOccurrence, notices: batch.notices, dropped: batch.dropped, ...(batch.activation !== undefined ? { activation: batch.activation as CoalescedOccurrence | null } : {}), diff --git a/test/coalescer-render-recovery.test.ts b/test/coalescer-render-recovery.test.ts index 218496afd..25ce707b8 100644 --- a/test/coalescer-render-recovery.test.ts +++ b/test/coalescer-render-recovery.test.ts @@ -31,6 +31,10 @@ function harness(published = new Set()) { let started!: () => void; const rendering = new Promise(resolve => { started = resolve; }); let hold = false; + let holdCommit = false; + let releaseCommit!: () => void; + let commitStarted!: () => void; + const committing = new Promise(resolve => { commitStarted = resolve; }); const coalescer = new PushCoalescer({ isUnread: () => false, remove: () => false, @@ -62,7 +66,11 @@ function harness(published = new Set()) { save: () => {}, saveNow: snapshot => { saved = structuredClone(snapshot); receipts.length = 0; }, recordReceipt: receipt => { receipts.push(structuredClone(receipt)); }, - commit: async () => {}, + commit: async () => { + if (!holdCommit) return; + commitStarted(); + await new Promise(resolve => { releaseCommit = resolve; }); + }, wasPublished: (_subject, id) => published.has(id), }); return { @@ -70,6 +78,9 @@ function harness(published = new Set()) { hold: () => { hold = true; }, release: () => { hold = false; release!({ content: [{ type: 'text', text: 'completed active render' }] }); }, saved: () => structuredClone(saved!), + holdCommit: () => { holdCommit = true; }, + committing, + releaseCommit: () => { holdCommit = false; releaseCommit(); }, }; } @@ -247,3 +258,37 @@ test('authority becoming unknown during render retains fallback until readiness assert.equal(f.delivered.length, 1); assert(f.delivered[0]!.body.includes('fallback:active'), 'do not repeat the interrupted render'); }); + +for (const secondCrash of ['before recovery assembly', 'during recovery commit'] as const) { + test('receipt-restored pending fallback keeps its role and order across a second crash ' + secondCrash, async () => { + const live = harness(); + await live.coalescer.accept(occurrence('r1')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('r2')); + const firstSnapshot = live.saved(); + const firstReceipts = structuredClone(live.receipts); + live.coalescer.suspend(); live.release(); await run; + + const middle = harness(); + middle.coalescer.restore(firstSnapshot); + middle.coalescer.restoreReceipts(firstReceipts); + let secondSnapshot: CoalescingSnapshot; + if (secondCrash === 'during recovery commit') { + middle.holdCommit(); + const recovering = middle.coalescer.assemble('agent'); + await middle.committing; + secondSnapshot = middle.saved(); + middle.coalescer.suspend(); middle.releaseCommit(); await recovering; + } else { + secondSnapshot = structuredClone(middle.coalescer.snapshot()); + } + const final = harness(); + final.coalescer.restore(secondSnapshot); + await final.coalescer.assemble('agent'); + assert.deepEqual(final.delivered.map(d => d.id), ['r1', 'r2'], 'fallback-only policy must not promote newer pending content ahead of older frozen work'); + assert.deepEqual(final.delivered.map(d => d.body), [JSON.stringify(occurrence('r1').content), JSON.stringify(occurrence('r2').content)]); + assert.equal(final.coalescer.pendingBatches(), 0); + }); +} diff --git a/test/conversation-self-echo.test.ts b/test/conversation-self-echo.test.ts index c7ee466df..01485e26d 100644 --- a/test/conversation-self-echo.test.ts +++ b/test/conversation-self-echo.test.ts @@ -628,3 +628,28 @@ test('a transient recovered spawn failure is retried by backoff without another assert(f.context(binding.agentName).includes('document_diff')); assert.equal(f.membrane.calls.length, 1); }); + +test('a mixed batch retains counterpart delivery when its binding disappears during render', async (t) => { + const f = await setup(t); + const router = f.framework.getConversationRouter()!; + await f.send('push/event', { + ...f.params('counterpart', 'fresh counterpart', { channelId: 'chat', key: 'lost-binding', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + const first = router.getBinding('chat')!; + assert(first); + await f.send('push/event', { + ...f.params('echo', 'self addition', { channelId: 'chat', key: 'lost-binding', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self' }, + }); + f.renderer(async () => { + router.unbind('chat'); + return { content: [{ type: 'text', text: 'rendered counterpart and echo' }] }; + }); + await f.framework.runUntilIdle(); + const replacement = router.getBinding('chat'); + assert(replacement, 'retained counterpart activity permits routing even though the latest content is an echo'); + assert.equal(replacement.generation, first.generation + 1); + assert(f.context(replacement.agentName).includes('rendered counterpart and echo')); + assert.equal(f.renders.length, 1); +}); From ea670065ab772da10ea2016d644c23ff60288c85 Mon Sep 17 00:00:00 2001 From: Sage-307 Date: Fri, 2 Oct 2026 14:30:58 -0400 Subject: [PATCH 12/12] fix(routing): carry activation binding facts through materialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felix reproduced the mixed-batch guard still dropping under the default mention-only channel policy. The retained activity boolean allowed route(), but route then read the latest self echo’s metadata, which lacked the counterpart’s qualifying mention. The prior always-bind test masked this. Carry host-derived mention and channel-kind facts from the retained activation separately from the latest content metadata. A materialized mixed batch can rebind using the same counterpart facts that qualified it; ordinary and self-only delivery keep their existing behavior. Do not overwrite the content author or metadata with the activation author. The always/mention binding-loss comparison produced 1 pass / 1 fail on 32da286. The corrected wire coverage also exercises DM classification when normal channels never bind. Rebuilt recovery/self-echo/silent-heartbeat tests pass 58 / 0; diff check clean. Independent routing re-review precedes the final bounded full run and push. Co-Authored-By: GPT-6 Astra --- src/framework.ts | 20 ++++++++++++++++---- test/conversation-self-echo.test.ts | 9 ++++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index c33424e70..cb0fac0d9 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -81,7 +81,7 @@ import { ProseStreamRouter } from './mcpl/prose-stream-router.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js'; -import { ConversationRouter } from './mcpl/conversation-router.js'; +import { ConversationRouter, type IncomingMessageFacts } from './mcpl/conversation-router.js'; import { safeSlice } from './safe-slice.js'; import type { WorkspaceModule } from './modules/workspace/index.js'; import { @@ -6902,6 +6902,7 @@ export class AgentFramework { deliverTo?: string; /** Host-owned deferred batch attribution, separate from rendered content. */ conversationActivity?: boolean; + conversationBinding?: Pick; }): Promise { const metadata: Record = { ...event.metadata, @@ -7048,6 +7049,7 @@ export class AgentFramework { triggerInference?: boolean; coalescingSubject?: string; conversationActivity?: boolean; + conversationBinding?: Pick; }, messageMetadata: Record, ): Promise { @@ -7060,8 +7062,8 @@ export class AgentFramework { const decision = router.route({ channelId: event.channelId, - mentioned: event.metadata?.mentioned === true, - kind: ConversationRouter.classifyChannel(descriptor, event.metadata), + mentioned: event.conversationBinding?.mentioned ?? (event.metadata?.mentioned === true), + kind: event.conversationBinding?.kind ?? ConversationRouter.classifyChannel(descriptor, event.metadata), }); if (decision.kind === 'unbound') { @@ -7737,7 +7739,17 @@ export class AgentFramework { // A rendered batch's latest content may be self-authored while a fresh // counterpart occurrence still supplies its pending activity. Never infer // activity from text the renderer happens to include from older history. - const activity = activation === undefined ? {} : { conversationActivity: activation !== null }; + // Rebinding must use the cause's mention/channel-kind facts too; the + // latest self echo may omit the very facts that qualified its counterpart. + const activationMetadata = activation?.event.lane === 'channel' + ? activation.event.event.metadata : activation?.event.event.origin; + const activity = activation === undefined ? {} : { + conversationActivity: activation !== null, + ...(activation ? { conversationBinding: { + mentioned: activationMetadata?.mentioned === true, + kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), activationMetadata), + } } : {}), + }; if (occ.event.lane === 'channel') { const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...activity, ...(assemblingFor ? { assemblingFor } : {}) }; const placement = await this.handleMcplChannelIncoming(event); diff --git a/test/conversation-self-echo.test.ts b/test/conversation-self-echo.test.ts index 01485e26d..5c7e1979a 100644 --- a/test/conversation-self-echo.test.ts +++ b/test/conversation-self-echo.test.ts @@ -629,12 +629,13 @@ test('a transient recovered spawn failure is retried by backoff without another assert.equal(f.membrane.calls.length, 1); }); -test('a mixed batch retains counterpart delivery when its binding disappears during render', async (t) => { - const f = await setup(t); +for (const bind of ['always', 'mention', 'dm'] as const) { +test('a mixed batch retains counterpart delivery under ' + bind + ' binding when its binding disappears during render', async (t) => { + const f = await setup(t, { framework: { conversations: { ...conversations, bind: { channel: bind === 'dm' ? 'never' : bind } } } }); const router = f.framework.getConversationRouter()!; await f.send('push/event', { ...f.params('counterpart', 'fresh counterpart', { channelId: 'chat', key: 'lost-binding', deferred: true }), - tags: ['chat:from-human'], origin: { authorId: 'human' }, + tags: ['chat:from-human'], origin: { authorId: 'human', mentioned: bind !== 'dm', ...(bind === 'dm' ? { channel_type: 'im' } : {}) }, }); const first = router.getBinding('chat')!; assert(first); @@ -653,3 +654,5 @@ test('a mixed batch retains counterpart delivery when its binding disappears dur assert(f.context(replacement.agentName).includes('rendered counterpart and echo')); assert.equal(f.renders.length, 1); }); + +}