fix: keep client session projection stores across removal and revival

The per-session ProjectionValueStore was deleted on host/session-removed
even though the Session instance survives removal (open() is idempotent and
drop() only removes the map entry). A re-added or re-listed session resumed
on the same instance with an empty projection face, so projection-backed UI
(such as the OpenRouter cost readout) reverted to blank until a fresh host
baseline landed.

Retain the store on removal and lift the removed tombstone from the
surviving instance on revival (host/session-added and list-refresh
re-listing), so chat switching keeps cost readouts populated.
This commit is contained in:
2026-08-20 22:10:43 +07:00
parent ed152416d5
commit d2ad46b8aa
8 changed files with 266 additions and 5 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-web-session-projection-store-survives-revival.md
2026-08-20-web-session-projection-store-survives-revival.md: 27340e87e9fd2dfbfd4bf1868f4aacc2d4af5fdb
2026-08-20-web-session-projection-store-survives-revival.zh.md: 63799da02266e1b2a724bb00659ea0fb5717d93d

View File

@@ -0,0 +1,52 @@
# Agent Note: Web session projection stores survive removal and revival
Status: implemented
English | [中文](2026-08-20-web-session-projection-store-survives-revival.zh.md)
## Problem
The client runtime's `ProjectionValueStore` — the per-session store backing
`useProjection('openRouterCost')`, `useProjection('title')`, and every other
projection face — was deleted on every `host/session-removed`. The session
instance itself is resident: `open()` is idempotent, `drop()` only removes
the `sessions` map entry, and removal is modeled as a `removed` flag on the
surviving instance. Deleting the projection store disagreed with that model:
when the session was re-added (`host/session-added`) or re-listed by a
`session.list` refresh, the same instance resumed without its projection
store, so projection-backed UI (notably the OpenRouter cost readout in the
composer dock) reverted to blank until the host re-served a projection
commit.
## Decision
The projection store follows the resident instance: `SessionManager` no
longer deletes it on `host/session-removed`. The `removed` tombstone is
lifted by a new `Session.handleRevived()` — called from the `host/session-added`
path and from the list-refresh summary push — which clears the flag and
records the store as dirty so subscribers re-read the retained values. A
re-added session therefore resumes its previous projection values through the
same instance; the host re-serves the projection rows on the next `session.list`
from its cached row set, and the store needs no re-seed.
## Alternatives considered
**Delete the store on removal and re-seed on re-add.** Rejected: the session
instance is not re-seeded by `open()` (idempotent), so a deletion would leave
the resumed session projection-blank until a fresh projection frame arrives.
Retention matches the resident-instance contract already in place.
**Drop the store only when the session is dropped from the list.** The removal
frame is the only cleanup signal the manager receives; there is no separate
lifecycle step distinguishing "tombstoned for revival" from "gone", so keeping
the store until `drop()` is the model-consistent rule.
## Consequences
A removed-but-resident session keeps its projection values for as long as the
instance exists, and re-listing restores them without a host re-seed. The
store retains memory for removed sessions until the manager drops the map
entry, which is the same tenure the session instance already held. `test:gui`
pins both revival paths — `host/session-added` and list-refresh re-listing —
plus the plain-switch retention case, and the projection-store spec pins that
a removed session's store keeps its folded values.

View File

@@ -0,0 +1,23 @@
# Agent Note: Web 会话投影存储在移除与复活后继续存活
Status: implemented
[English](2026-08-20-web-session-projection-store-survives-revival.md) | 中文
## 问题
客户端运行时的 `ProjectionValueStore` —— 支撑 `useProjection('openRouterCost')``useProjection('title')` 以及其他所有投影面face的按会话存储 —— 会在每次 `host/session-removed` 时被删除。会话实例本身是常驻的:`open()` 幂等,`drop()` 只删除 `sessions` 映射项,移除被建模为幸存实例上的 `removed` 标志。删除投影存储与该模型不一致:当会话被重新添加(`host/session-added`)或由 `session.list` 刷新重新列出时,同一实例继续运行却没有其投影存储,于是投影驱动的 UI尤其是 composer dock 中的 OpenRouter 费用读数)会回退为空白,直到宿主重新下发投影提交。
## 决策
投影存储随常驻实例存活:`SessionManager` 不再在 `host/session-removed` 时删除它。`removed` 墓碑由新增的 `Session.handleRevived()` 解除——它从 `host/session-added` 路径与列表刷新摘要推送两处调用——清除标志并将存储标记为脏,使订阅者重新读取保留的值。因此被重新添加的会话通过同一个实例恢复之前的投影值;宿主在下一次 `session.list` 时从缓存的投影行集合重新下发这些行,存储无需重新播种。
## 考虑过的替代方案
**移除时删除存储,重新添加时重新播种。** 否决:`open()` 幂等,不会为会话重新播种,因此删除会让恢复的会话在收到新投影帧之前一直投影空白。保留与既有的常驻实例契约一致。
**仅当会话从列表移除时才丢弃存储。** 移除帧是管理器收到的唯一清理信号;没有单独的「为复活而墓碑化」与「真正消失」生命周期步骤,因此在 `drop()` 之前保留存储是模型一致的规则。
## 结论
被移除但常驻的会话在其实例存活期间保留投影值,重新列出时无需宿主重新播种即可恢复。在管理器删除映射项之前,存储会为已移除会话保留内存——这与会话实例本身已有的任期相同。`test:gui` 钉定了两条复活路径(`host/session-added` 与列表刷新重新列出),外加普通切换保留场景;投影存储规范钉定被移除会话的存储会保留其折叠后的值。