From 069f2644fffd3ea81d997ce5d35a02afecfcbfa0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:11:34 +0800 Subject: [PATCH] fix(web): preserve removal across stale catalog pulls The earlier removal fix invalidated parentAvailable immediately and queued a trailing subagent.list request, but it still applied the already in-flight success verbatim. That stale success reopened the composer and became the trailing request baseline. If the trailing request failed, its error snapshot preserved parentAvailable:true indefinitely. Record a false-only parent availability override on the exact in-flight catalog request when the owner removal frame arrives. Successful and failed responses now replay that request-local invalidation before publishing a snapshot, and addressed child Sessions receive the same effective value. The trailing request therefore starts from a false baseline and a later transport or business failure cannot resurrect the removed parent. Strengthen the regression to assert the catalog and selected child remain read-only immediately after a stale parentAvailable:true success, then fail the trailing pull and assert the error snapshot remains unavailable. The complete SessionManager test file passes all 39 tests, and the client runtime TypeScript project builds cleanly. --- .../runtime/src/client/sessions/manager.ts | 31 ++++++++++++++----- packages/client/runtime/tests/manager.spec.ts | 14 +++++++-- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 2d20875adb..96b790abd0 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -59,6 +59,8 @@ interface CatalogInflight { readonly promise: Promise readonly expandableRows: Set readonly activityRows: Map + /** Removal-time invalidation replayed over the response this request predates. */ + parentAvailableOverride: false | undefined } type SessionListMutation = @@ -312,22 +314,26 @@ export class SessionManager { try { const { result } = await this.api.subagents.list({ parentSessionId }) if (result.ok) { + const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? result.value.parentAvailable this.catalogs.set(parentSessionId, { ...result.value, entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows), + parentAvailable, state: 'ready', error: null, }) for (const [childId, address] of this.addresses) { if (address.parentSessionId !== parentSessionId) continue - this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable) + this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable) } } else { this.catalogs.set(parentSessionId, { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: result.error, }) @@ -338,7 +344,8 @@ export class SessionManager { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: folded.ok ? null : folded.error, }) @@ -351,7 +358,12 @@ export class SessionManager { this.notifier.markDirty() } })() - this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows }) + this.catalogInflight.set(parentSessionId, { + promise: operation, + expandableRows, + activityRows, + parentAvailableOverride: undefined, + }) return operation } @@ -690,9 +702,14 @@ export class SessionManager { if (!durableSubagent) this.projectionStores.delete(frame.sessionId) // A pull already in flight was requested before this removal and can // carry the pre-removal parentAvailable:true, which would resurrect - // the writable editor this invalidation just closed. Queue one - // trailing refresh so the post-removal host truth converges. - if (this.catalogInflight.has(frame.sessionId)) this.catalogStale.add(frame.sessionId) + // the writable editor this invalidation just closed. Replay false over + // that response and queue one trailing refresh so the post-removal + // host truth converges. + const inflightCatalog = this.catalogInflight.get(frame.sessionId) + if (inflightCatalog !== undefined) { + inflightCatalog.parentAvailableOverride = false + this.catalogStale.add(frame.sessionId) + } // The removed session can no longer be the delivery owner of its // catalog: invalidate availability immediately. Removal schedules no // catalog refresh, and without this an addressed child keeps a diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index c37f0b88a1..8ec3df98d8 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -588,7 +588,7 @@ describe('subagent catalogs', () => { } }) - it('does not let a stale in-flight pull resurrect a removed parent\'s availability', async () => { + it('keeps removal invalidation across a stale success and failed trailing pull', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId const child = () => ({ @@ -616,8 +616,16 @@ describe('subagent catalogs', () => { api.onSubagentList = () => trailing.promise mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await midRefresh - trailing.resolve(ok({ entries: [child()] as never[], parentAvailable: false })) - await trailing.promise + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + + trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} })) + await vi.waitFor(() => { + expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ + state: 'error', + parentAvailable: false, + }) + }) const rootCalls = api.callsOf('subagent.list') .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root)