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:
@@ -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()
|
||||
}
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user