perf(web): trail only membership-invalidated catalogs

refreshSubagents previously treated every overlapping caller as proof that the in-flight response was stale. Selection, menu opening, and reconnect paths can legitimately request the same catalog concurrently without any host mutation, so those reads were coalesced and then followed by an unnecessary second RPC.

Restore ordinary in-flight coalescing at the public refresh boundary. The debounced host/session-added path now owns the membership-specific stale mark: if its timer fires during an older pull, it queues one trailing request; otherwise it starts the refresh directly. Parent removal keeps its separate explicit invalidation and trailing-refresh path.

Add a regression proving two overlapping reads share one Promise and issue one RPC. Rework the membership test to start from a restored selected parent, so only the host membership frame can request the trailing pull instead of the test priming the stale bit with an unrelated duplicate read.

Validated with both focused catalog cases, all 40 SessionManager tests, and the client runtime TypeScript project build.
This commit is contained in:
Tianyi Cui
2026-08-02 20:13:28 +08:00
parent 069f2644ff
commit 7b40fd5419
2 changed files with 26 additions and 13 deletions

View File

@@ -290,16 +290,7 @@ export class SessionManager {
*/ */
refreshSubagents(parentSessionId: SessionId): Promise<void> { refreshSubagents(parentSessionId: SessionId): Promise<void> {
const existing = this.catalogInflight.get(parentSessionId) const existing = this.catalogInflight.get(parentSessionId)
if (existing !== undefined) { if (existing !== undefined) return existing.promise
// 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 previous = this.catalogs.get(parentSessionId)
const expandableRows = new Set<SessionId>() const expandableRows = new Set<SessionId>()
const activityRows = new Map<SessionId, 'running' | 'inactive'>() const activityRows = new Map<SessionId, 'running' | 'inactive'>()
@@ -774,11 +765,18 @@ export class SessionManager {
for (const session of this.sessions.values()) void session.resync() for (const session of this.sessions.values()) void session.resync()
} }
/** Debounce membership refetches while one parent catalog is open. */ /** Debounce membership refetches while one parent catalog is selected or open. */
private scheduleCatalogRefresh(parentSessionId: SessionId): void { private scheduleCatalogRefresh(parentSessionId: SessionId): void {
if (this.catalogDebounce.has(parentSessionId)) return if (this.catalogDebounce.has(parentSessionId)) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.catalogDebounce.delete(parentSessionId) this.catalogDebounce.delete(parentSessionId)
// The in-flight response predates the membership frame that scheduled
// this callback. Queue one post-settlement pull instead of treating an
// ordinary overlapping read as evidence that catalog membership changed.
if (this.catalogInflight.has(parentSessionId)) {
this.catalogStale.add(parentSessionId)
return
}
void this.refreshSubagents(parentSessionId) void this.refreshSubagents(parentSessionId)
}, 50) }, 50)
this.catalogDebounce.set(parentSessionId, timer) this.catalogDebounce.set(parentSessionId, timer)

View File

@@ -530,6 +530,22 @@ describe('subagent catalogs', () => {
]) ])
}) })
it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
expect(manager.refreshSubagents(root)).toBe(refresh)
api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
first.resolve(ok({ entries: [], parentAvailable: true }))
await refresh
expect(api.callsOf('subagent.list')).toHaveLength(1)
})
it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
vi.useFakeTimers() vi.useFakeTimers()
try { try {
@@ -538,8 +554,7 @@ describe('subagent catalogs', () => {
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>() const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>() const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise api.onSubagentList = () => first.promise
const manager = new SessionManager(api) const manager = new SessionManager(api, root)
manager.setSubagentCatalogOpen(root, true)
const refresh = manager.refreshSubagents(root) const refresh = manager.refreshSubagents(root)
// A membership frame arrives while the pull is in flight; the debounced // A membership frame arrives while the pull is in flight; the debounced