Forráskód Böngészése

fix(web): keep removal-time availability invalidation across an in-flight pull

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.
Tianyi Cui 1 hónapja
szülő
commit
295e56b61e

+ 5 - 0
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

+ 38 - 0
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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
+    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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
+    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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
+    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