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/4] 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/4] 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) From 65cb72209884f33610972f6c42c0adbb535d178d Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 26 Sep 2026 21:36:31 +0800 Subject: [PATCH 3/4] fix(runtime-host): seed a rootless snapshot with its authoritative queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Desktop that navigates away unsubscribes, so the projector fix that projects a rootless drain never reaches it: the drain lands while no subscriber is connected, and on navigating back the fresh projector's seedActive returned no events for a rootless snapshot. The client's stale queued card survived — retracting it fails with not_found — until an update that will never come. The rootless seed now emits the authoritative queue once, empty or not; the Desktop queue projection keys by Session and retires the card on the empty update. Carries the #5520 review finding; covered by a projector seed test and a Desktop queue-projection regression for switch away, drain, switch back. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/message-queue-ui-state.test.ts | 63 +++++++++++++++++++ .../src/__tests__/session-projector.test.ts | 25 ++++++++ .../src/adapter/session-projector.ts | 8 ++- 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index a93eb73849..57eb3433f0 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -203,6 +203,69 @@ test('queue_update events drive the independent desktop queue projection', () => assert.equal(transientMessages.size, 0); }); +test('a rootless resubscription seed retires a stale queued card', () => { + // Switch away → the queue drains rootless → navigate back. The projector's + // rootless seed now carries the authoritative queue (apache/maka#5520 + // review), so the card the client kept from before it left must go. + const controller = createAppShellSessionUiStateController(); + const handlers = createAppShellSessionEventHandlers({ + uiLocale: 'zh-CN', + activeIdRef: { current: 'session-1' }, + liveTurnBySessionRef: controller.liveTurnBySessionRef, + refreshMessages: async () => true, + refreshSessions: async () => [], + setLiveTurnBySession: controller.setLiveTurnBySession, + setInteractionBySession: controller.setInteractionBySession, + setMessageQueueBySession: controller.setMessageQueueBySession, + removeTransientMessage: () => {}, + showModelSetupToast() {}, + toastApi: { error() {} }, + }); + + handlers.handleEvent('session-1', { + type: 'queue_update', + id: 'queue-1', + turnId: 'turn-1', + ts: 1, + queueRevision: 3, + steering: ['adjust this run'], + followup: [], + steeringEntries: [ + { + entryId: 'entry-steer', + messageId: 'message-steer', + content: { text: 'adjust this run' }, + placement: 'current_turn' as const, + state: 'queued' as const, + }, + ], + followupEntries: [], + }); + assert.ok( + controller.getState().messageQueueBySession['session-1'], + 'the card is visible before the client leaves', + ); + + // The resubscription seed's authoritative empty queue: the drain landed + // while the Session was inactive, and the root Turn is gone. + handlers.handleEvent('session-1', { + type: 'queue_update', + id: 'host-queue:host-1:4', + turnId: '', + ts: 2, + queueRevision: 4, + steering: [], + followup: [], + steeringEntries: [], + followupEntries: [], + }); + assert.equal( + controller.getState().messageQueueBySession['session-1'], + undefined, + 'the stale card does not survive the resubscription', + ); +}); + test('steering delivery clears a promoted follow-up from the desktop queue', () => { const controller = createAppShellSessionUiStateController(); const handlers = createAppShellSessionEventHandlers({ diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 078390d33e..c08e943654 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -463,6 +463,31 @@ test('projects a queue drain that lands while no root Turn is live', () => { assert.deepEqual(update.followup, []); }); +test('seeding a rootless snapshot conveys the authoritative queue', () => { + // A Desktop that navigates away unsubscribes; if the queue drains while the + // Session is inactive, the resubscribing client's stale queued card survives + // until a queue_update that the rootless seed never produced (apache/maka + // #5520 review). The rootless seed must carry the authoritative queue once. + const projector = new RuntimeHostSessionProjector( + snapshot({ rootTurn: null, queue: queue(3, []) }), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const seeded = projector.seedActive(true); + assert.deepEqual( + seeded.map((event) => event.type), + ['queue_update'], + ); + const update = seeded.find( + (event): event is Extract => + event.type === 'queue_update', + ); + assert.ok(update); + assert.deepEqual(update.steering, []); + assert.deepEqual(update.followup, []); +}); + 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 6dba31a6e4..2acf66bad1 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -160,7 +160,13 @@ export class RuntimeHostSessionProjector { seedActive(includeAssistantText: boolean): SessionEvent[] { const root = this.#snapshot.rootTurn; - if (!root) return []; + if (!root) { + // A Session whose root Turn is gone still owns an authoritative queue: + // a client resubscribing after navigating away may hold a stale queued + // card that only this seed can retire, because no live drain will run + // while it is the active view (apache/maka#5520 review). + return [projectQueueUpdate(this.#unplacedQueue(this.#snapshot.queue), '', this.#now())]; + } const events: SessionEvent[] = []; const queueEvents = this.#projectMessageAdmissions || queueHasEntries(this.#snapshot.queue) From eba044200976a9e72ca15b76033ea7a369a3cc98 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 26 Sep 2026 22:07:53 +0800 Subject: [PATCH 4/4] fix(runtime-host): tolerate queue-less rootless snapshots in the seed Desktop test fixtures build rootless continuity snapshots without a queue field, and the seed's new authoritative-queue emission read straight into unplacedQueue. Project an empty queue when the snapshot carries none, matching the update path's tolerance; the observer seed expectation now reflects the queue_update the rootless seed carries. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/runtime-host-session-observer.test.ts | 8 +++++++- .../runtime-host/src/adapter/session-projector.ts | 11 +++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index e92339ef85..73f9582c4e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -77,7 +77,13 @@ test('projects root lifecycle without fabricating content events', async (t) => if (seed?.type === 'host_observation_seed') { assert.deepEqual(seed.observerIds, ['execution-observer']); assert.equal(seed.execution.rootTurn, null); - assert.deepEqual(seed.events, []); + // The rootless seed carries the authoritative queue once, so a stale + // queued card from an earlier observation retires on re-subscription + // (apache/maka#5520 review). + assert.deepEqual( + seed.events.map((event) => event.type), + ['queue_update'], + ); } const seededCount = messages.length; events.push({ diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 2acf66bad1..36c1d639a6 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -164,8 +164,15 @@ export class RuntimeHostSessionProjector { // A Session whose root Turn is gone still owns an authoritative queue: // a client resubscribing after navigating away may hold a stale queued // card that only this seed can retire, because no live drain will run - // while it is the active view (apache/maka#5520 review). - return [projectQueueUpdate(this.#unplacedQueue(this.#snapshot.queue), '', this.#now())]; + // while it is the active view (apache/maka#5520 review). Snapshots that + // carry no queue at all project as empty. + const queue = this.#snapshot.queue ?? { + hostEpoch: '', + queueRevision: 0, + steering: [], + followup: [], + }; + return [projectQueueUpdate(this.#unplacedQueue(queue), '', this.#now())]; } const events: SessionEvent[] = []; const queueEvents =