From 295e56b61ec64ea366a215869085e0e580e6efd6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:46:51 +0800 Subject: [PATCH] fix(web): keep removal-time availability invalidation across an in-flight pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `host/session-removed` invalidation flipped the owned catalog and addressed children to `parentAvailable:false`, but a `subagent.list` pull already in flight was requested before the removal and its ok-response carries the pre-removal `parentAvailable:true` — the response then overwrote both the catalog and every addressed child, resurrecting the writable-editor-against-a-dead-continuation-owner bug the invalidation closes, with no refresh scheduled to converge afterwards. Mark the owner stale when a pull is in flight at removal time, so one trailing refresh runs after the in-flight response settles and the post-removal host truth lands. Adds a regression test: removal mid-pull, stale ok response, trailing pull, final state stays unavailable on the catalog and the addressed child. --- .../runtime/src/client/sessions/manager.ts | 5 +++ packages/client/runtime/tests/manager.spec.ts | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 1c186059de..2d20875adb 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -688,6 +688,11 @@ export class SessionManager { this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone 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 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 5d12498edd..2d456a9c34 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -588,6 +588,44 @@ describe('subagent catalogs', () => { } }) + it('does not let a stale in-flight pull resurrect a removed parent\'s availability', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const child = () => ({ + kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker', + activity: 'inactive' as const, hasChildren: false, + }) + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + const refresh = manager.refreshSubagents(root) + first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + await refresh + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + + // The removal lands while a second pull is in flight: the invalidation + // must survive the pre-removal ok response, so one trailing pull runs. + const mid = deferred>>() + api.onSubagentList = () => mid.promise + const midRefresh = manager.refreshSubagents(root) + manager.handleHostEnvelope({ + rpcId: 'parent-removed-mid-pull' as never, + payload: { type: 'host/session-removed', sessionId: root }, + }) + const trailing = deferred>>() + 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 + + const rootCalls = api.callsOf('subagent.list') + .filter((call: { parentSessionId: SessionId }) => call.parentSessionId === root) + expect(rootCalls).toHaveLength(3) + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) + it('invalidates catalog availability when the owning parent is removed', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId