fix(web): run a trailing catalog refresh for coalesced membership changes

`refreshSubagents` single-flights per catalog owner: a request arriving
while a pull is in flight returns the in-flight promise and is silently
coalesced into it. The in-flight response was requested before the
triggering change, so it can never contain that change — a debounced
membership refresh (50ms after `host/session-added`) firing during a slow
pull therefore lost the new child, and the catalog stayed stale until an
unrelated trigger (reselection, menu reopen, reconnect).

Mark the owner stale on coalescing and re-arm one trailing pull in the
settlement `finally`, so every membership change observed during a pull is
carried by a follow-up refresh exactly once. Bounded: the trailing pull
only runs when a refresh request was actually coalesced, and a new
coalescing during the trailing pull re-marks the same set.

Adds a fake-timer regression test: a `host/session-added` debounce firing
mid-pull yields exactly two `subagent.list` calls and the catalog
eventually contains the new child.
This commit is contained in:
Tianyi Cui
2026-08-02 12:18:34 +08:00
parent ab320ba991
commit b270b3ef9f
2 changed files with 74 additions and 1 deletions
@@ -101,6 +101,8 @@ export class SessionManager {
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
private readonly catalogInflight = new Map<SessionId, CatalogInflight>()
/** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
@@ -286,7 +288,16 @@ export class SessionManager {
*/
refreshSubagents(parentSessionId: SessionId): Promise<void> {
const existing = this.catalogInflight.get(parentSessionId)
if (existing !== undefined) return existing.promise
if (existing !== undefined) {
// A refresh requested while a pull is in flight must not be silently
// coalesced into it: the in-flight response was requested before the
// triggering change (a membership frame or an opened menu), so it can
// never contain that change. Queue one trailing refresh that runs after
// the pull settles; without it the change stays invisible until an
// unrelated later trigger (reselection, menu reopen, reconnect).
this.catalogStale.add(parentSessionId)
return existing.promise
}
const previous = this.catalogs.get(parentSessionId)
const expandableRows = new Set<SessionId>()
const activityRows = new Map<SessionId, 'running' | 'inactive'>()
@@ -333,6 +344,10 @@ export class SessionManager {
})
} finally {
this.catalogInflight.delete(parentSessionId)
// Re-arm the trailing pull before the dirty notify: the response the
// caller observed predates the stale-marking change, so the follow-up
// refresh is the only carrier of that change.
if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId)
this.notifier.markDirty()
}
})()