fix(web): discard projection state across host generations

This commit is contained in:
_Kerman committed 2026-09-09 16:28:03 +08:00
1 parent e101c448e5
commit f3332af2a4
11 files changed
+178 -17

No files matched your search

@@ -114,7 +114,7 @@ export class SessionManager {
private listPhase: SessionListPhase = 'pending'
private listError: RemoteFailure | null = null
private listInflight: Promise<void> | null = null
/** Mutations arriving after a list request starts are replayed over its response. */
/** Active list request's mutation log; its identity also fences completion after reconnect. */
private listMutations: SessionListMutation[] | null = null
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
@@ -441,7 +441,7 @@ export class SessionManager {
// ---- List API ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
/** Full refresh via session.list (single-flight within one Host generation). */
refreshList(): Promise<void> {
if (this.listInflight !== null) return this.listInflight
this.listState = 'loading'
@@ -453,6 +453,7 @@ export class SessionManager {
this.listInflight = (async () => {
try {
const result = await this.remote.session.list({})
if (this.listMutations !== mutations) return
if (result.ok) {
const baseline: SessionSummary[] = this.listPhase === 'pending'
? [...result.value.items]
@@ -502,12 +503,15 @@ export class SessionManager {
}
} catch (error) {
if (!isRemoteFailure(error)) throw error
if (this.listMutations !== mutations) return
this.listState = 'error'
this.listError = error
} finally {
this.listMutations = null
this.listInflight = null
this.notifier.markDirty()
if (this.listMutations === mutations) {
this.listMutations = null
this.listInflight = null
this.notifier.markDirty()
}
}
})()
return this.listInflight
@@ -766,9 +770,14 @@ export class SessionManager {
/**
* Repair one re-established Host-event generation with queryable baselines.
* Discard old projection cuts before new queries, including cold Sessions
* absent from the process-local control baseline.
* Opened Session follow streams resume independently through API Gateway.
*/
handleConnected(): void {
for (const store of this.projectionStores.values()) store.clear()
this.listMutations = null
this.listInflight = null
void this.refreshList()
const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)
@@ -67,9 +67,9 @@ interface Channel {
/**
* One session's projection values. Framework semantics, uniform across every
* key: a baseline seeds rows at its cut, a push frame updates one row, and in
* both paths a lower-or-equal seq loses — a replayed frame cannot regress a
* value, a stale baseline cannot overwrite a newer frame. A key the store has
* never seen reads `undefined` (capability absent). Faces are identity-stable
* both paths a lower-or-equal seq within the Host generation loses. A replayed
* frame cannot regress a value; a stale baseline cannot overwrite a newer
* frame. A key the store has never seen reads `undefined` (capability absent). Faces are identity-stable
* per key (create-on-demand, cached) so the React side binds each exactly
* once; the store-level channel (`subscribeAny`) serves coarse consumers (the
* manager's list projection reads the `title` key).
@@ -174,6 +174,14 @@ export class ProjectionValueStore {
}
}
/** Discard one Host generation's values and watermarks while preserving subscribed faces. */
clear(): void {
for (const key of this.rows.keys()) {
this.rows.delete(key)
this.changed(key)
}
}
private changed(key: string): void {
this.valuesCache = undefined
this.channels.get(key)?.notifier.markDirty()