From 7b4a974e640bc96ffb8f9abf8fc3f326fc04605b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Sun, 20 Sep 2026 12:47:02 +0800 Subject: [PATCH 1/2] fix(runtime-host): project queue changes that land while no root Turn is live The session projector pushed `queue_update` only when a root Turn existed (`root && queueChanged(...)`), and `seedActive` returned no events at all for a snapshot without a root Turn. A queue that drained inside that window - the queued follow-up consumed as the Turn went terminal, or a drain between subscriptions - was therefore never projected: the renderer kept the queued card, and every retract of it failed with `not_found` (`queue.entry.retract`) while the failure path deliberately left the projection unchanged. Switching sessions back and forth did not clear it either, because the reseed hit the same gate. Project the authoritative queue whenever it changes, independent of a live root Turn (attributing the event to the previous root when the drain lands in the same frame the Turn disappears), and seed the queue mirror on subscribe even for an idle session - the empty queue is exactly what tells an observer to drop a stale card. The steering synthesis stays root-gated: it attributes messages to the live Turn. Fixes #5520 Generated-by: GLM-5.3-Flash (ZCode) --- .../src/__tests__/session-projector.test.ts | 47 ++++++++++++++++ .../src/adapter/session-projector.ts | 55 ++++++++++++------- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 129efa5359..fb2bc54f8e 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -431,6 +431,53 @@ test('reseeds an empty queue after queued successors completed while disconnecte assert.deepEqual(queue.followupEntries, []); }); +test('projects a queue drain that lands while no root Turn is live', () => { + // apache/maka#5520: a drain observed after the root Turn is gone must still + // reach the renderer, or a phantom queued card survives whose retract fails + // with not_found forever. + const projector = new RuntimeHostSessionProjector( + snapshot({ queue: queue(2, [steeringEntry('queued')]) }), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const drained = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot({ projectionRevision: 2, rootTurn: null, queue: queue(3, []) }), + }); + assert.deepEqual( + drained.events.map((event) => event.type), + ['queue_update'], + ); + const update = drained.events.find( + (event): event is Extract => + event.type === 'queue_update', + ); + assert.ok(update, 'the drained queue must be projected'); + assert.deepEqual(update.steering, []); + assert.deepEqual(update.followup, []); +}); + +test('reseeds the queue mirror even when no root Turn is live', () => { + // apache/maka#5520: resubscribing to an idle session whose queue drained + // must still seed the authoritative (empty) queue, so a stale renderer card + // is cleared on session switch instead of surviving forever. + const current = snapshot({ rootTurn: null, queue: queue(7, []) }); + const projector = new RuntimeHostSessionProjector( + current, + createRuntimeHostSessionProjectionSeed([], current), + () => 10, + ); + const seeded = projector.seedActive(false).find((event) => event.type === 'queue_update'); + assert.ok(seeded, 'an idle session must still seed its authoritative queue state'); + assert.equal(seeded.queueRevision, 7); + assert.deepEqual(seeded.steeringEntries, []); + assert.deepEqual(seeded.followupEntries, []); +}); + test('reseeds the latest provider retry when the active Turn still carries one', () => { const retry = { phase: 'scheduled' as const, diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 944644e1c2..67c6d9b072 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -163,7 +163,13 @@ export class RuntimeHostSessionProjector { seedActive(includeAssistantText: boolean): SessionEvent[] { const root = this.#snapshot.rootTurn; - if (!root) return []; + if (!root) { + // The queue mirror is authoritative even with no live Turn: a subscriber + // (re)attaching to an idle session must still learn the current queue — + // including an empty one, so a queued card it rendered earlier is cleared + // instead of surviving as a phantom (apache/maka#5520). + return [projectQueueUpdate(this.#snapshot.queue, '', this.#now())]; + } const events: SessionEvent[] = []; const queueEvents = this.#projectMessageAdmissions || queueHasEntries(this.#snapshot.queue) @@ -424,28 +430,35 @@ export class RuntimeHostSessionProjector { for (const interaction of newlyPendingInteractions(previousSnapshot, next)) { events.push(...projectRuntimeHostInteractionRequest(interaction, this.#now())); } + const queueChangedNow = queueChanged(previousSnapshot.queue, next.queue); const enteredActiveTurn = - root && queueChanged(previousSnapshot.queue, next.queue) - ? newlyInFlight(previousSnapshot.queue, next.queue) - : []; - if (root && queueChanged(previousSnapshot.queue, next.queue)) { - for (const entry of enteredActiveTurn) { - if ( - this.#durableTurnByMessage.has(entry.messageId) || - this.#renderedSteeringMessageIds.has(entry.messageId) - ) - continue; - this.#renderedSteeringMessageIds.add(entry.messageId); - events.push({ - type: 'steering_message', - id: `host-queue:${next.queue.hostEpoch}:${next.queue.queueRevision}:${entry.entryId}`, - turnId: root.turnId, - messageId: entry.messageId, - ts: this.#now(), - content: structuredClone(entry.content), - }); + root && queueChangedNow ? newlyInFlight(previousSnapshot.queue, next.queue) : []; + if (queueChangedNow) { + if (root) { + for (const entry of enteredActiveTurn) { + if ( + this.#durableTurnByMessage.has(entry.messageId) || + this.#renderedSteeringMessageIds.has(entry.messageId) + ) + continue; + this.#renderedSteeringMessageIds.add(entry.messageId); + events.push({ + type: 'steering_message', + id: `host-queue:${next.queue.hostEpoch}:${next.queue.queueRevision}:${entry.entryId}`, + turnId: root.turnId, + messageId: entry.messageId, + ts: this.#now(), + content: structuredClone(entry.content), + }); + } } - events.push(projectQueueUpdate(next.queue, root.turnId, this.#now())); + // Project the authoritative queue even with no live Turn: a drain that + // lands after the root Turn is gone must still reach observers, or a + // queued card survives as a phantom whose retract fails with not_found + // (apache/maka#5520). + events.push( + projectQueueUpdate(next.queue, root?.turnId ?? previousRoot?.turnId ?? '', this.#now()), + ); } if (startedTurn) this.#accumulators.clear(); // Emit the presentation-only compaction-started event when the root Turn From 5dac0705ab88f74ec3c3f7793a7ed4e0dbad31c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Sun, 20 Sep 2026 13:11:27 +0800 Subject: [PATCH 2/2] fix(runtime-host): keep queue seeding silent for rootless snapshots The Desktop session observer pins an empty seed event list for a snapshot without a root Turn ("projects root lifecycle without fabricating content events"), and the projector snapshots it seeds from can carry no queue at all - the unconditional rootless seed both fabricated a queue_update the contract forbids and crashed on the missing field. The connected path is where the #5520 phantom actually lives: the drain lands while the client is subscribed (session events route per session, not per visible view), so projecting it there clears the card. A client that never observed the session has no stale card for a seed to clear, which is why seeding stays silent. Fixes the regression introduced in 7b4a974e6. Generated-by: GLM-5.3-Flash (ZCode) --- .../src/__tests__/session-projector.test.ts | 21 +++---------------- .../src/adapter/session-projector.ts | 8 +------ 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index fb2bc54f8e..d05e63e7ae 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -434,7 +434,9 @@ test('reseeds an empty queue after queued successors completed while disconnecte test('projects a queue drain that lands while no root Turn is live', () => { // apache/maka#5520: a drain observed after the root Turn is gone must still // reach the renderer, or a phantom queued card survives whose retract fails - // with not_found forever. + // with not_found forever. Seeding stays silent for rootless snapshots — the + // Desktop observer pins an empty seed there — because a client that never + // observed the session has no stale card to clear. const projector = new RuntimeHostSessionProjector( snapshot({ queue: queue(2, [steeringEntry('queued')]) }), createRuntimeHostSessionProjectionSeed([], snapshot()), @@ -461,23 +463,6 @@ test('projects a queue drain that lands while no root Turn is live', () => { assert.deepEqual(update.followup, []); }); -test('reseeds the queue mirror even when no root Turn is live', () => { - // apache/maka#5520: resubscribing to an idle session whose queue drained - // must still seed the authoritative (empty) queue, so a stale renderer card - // is cleared on session switch instead of surviving forever. - const current = snapshot({ rootTurn: null, queue: queue(7, []) }); - const projector = new RuntimeHostSessionProjector( - current, - createRuntimeHostSessionProjectionSeed([], current), - () => 10, - ); - const seeded = projector.seedActive(false).find((event) => event.type === 'queue_update'); - assert.ok(seeded, 'an idle session must still seed its authoritative queue state'); - assert.equal(seeded.queueRevision, 7); - assert.deepEqual(seeded.steeringEntries, []); - assert.deepEqual(seeded.followupEntries, []); -}); - test('reseeds the latest provider retry when the active Turn still carries one', () => { const retry = { phase: 'scheduled' as const, diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 67c6d9b072..521ef06574 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -163,13 +163,7 @@ export class RuntimeHostSessionProjector { seedActive(includeAssistantText: boolean): SessionEvent[] { const root = this.#snapshot.rootTurn; - if (!root) { - // The queue mirror is authoritative even with no live Turn: a subscriber - // (re)attaching to an idle session must still learn the current queue — - // including an empty one, so a queued card it rendered earlier is cleared - // instead of surviving as a phantom (apache/maka#5520). - return [projectQueueUpdate(this.#snapshot.queue, '', this.#now())]; - } + if (!root) return []; const events: SessionEvent[] = []; const queueEvents = this.#projectMessageAdmissions || queueHasEntries(this.#snapshot.queue)