@@ -38,6 +38,7 @@ public partial class OpenClawGatewayClient : WebSocketClientBase, IOperatorGatew
3838 private GatewayUsageStatusInfo ? _usageStatus ;
3939 private GatewayCostUsageInfo ? _usageCost ;
4040 private readonly Dictionary < string , string > _pendingRequestMethods = new ( ) ;
41+ private readonly Dictionary < string , int > _pendingRequestConnectionGenerations = new ( ) ;
4142 private readonly Dictionary < string , TaskCompletionSource < ChatSendResult > > _pendingChatSendRequests = new ( ) ;
4243 private readonly object _pendingRequestLock = new ( ) ;
4344 private readonly object _pendingChatSendLock = new ( ) ;
@@ -143,7 +144,6 @@ private void ResetUnsupportedMethodFlags()
143144 _agentsListUnsupported = false ;
144145 _agentFilesListUnsupported = false ;
145146 _agentFileGetUnsupported = false ;
146- _operatorReadScopeUnavailable = false ;
147147 }
148148
149149 /// <summary>
@@ -732,7 +732,8 @@ public async Task<JsonElement> SendWizardRequestAsync(
732732 /// <summary>Request session list from gateway.</summary>
733733 public async Task RequestSessionsAsync ( string ? agentId = null )
734734 {
735- if ( _operatorReadScopeUnavailable ) return ;
735+ var scopeState = CaptureOperatorReadScopeState ( ) ;
736+ if ( scopeState . Unavailable ) return ;
736737 try
737738 {
738739 var snapshot = await QuerySessionsAsync ( new SessionQuery
@@ -754,23 +755,95 @@ public async Task RequestSessionsAsync(string? agentId = null)
754755 }
755756 catch ( GatewayRequestException ex ) when ( IsMissingScopeError ( ex . Message , "operator.read" ) )
756757 {
757- _operatorReadScopeUnavailable = true ;
758- _logger . Warn ( "Gateway token lacks operator.read; disabling sessions polling" ) ;
758+ MarkOperatorReadScopeUnavailable ( scopeState . Generation ) ;
759759 }
760760 catch ( Exception ex )
761761 {
762762 _logger . Warn ( $ "sessions.list failed: { ex . Message } ") ;
763763 }
764764 }
765765
766- public Task < SessionQuerySnapshot > QuerySessionsAsync (
766+ public async Task < SessionQuerySnapshot > QuerySessionsAsync (
767767 SessionQuery query ,
768768 CancellationToken cancellationToken = default )
769769 {
770770 ArgumentNullException . ThrowIfNull ( query ) ;
771- return string . IsNullOrWhiteSpace ( query . Search )
772- ? _sessionQueries . LoadRecentAsync ( query , cancellationToken )
773- : _sessionQueries . SearchAsync ( query , cancellationToken ) ;
771+ cancellationToken . ThrowIfCancellationRequested ( ) ;
772+ var scopeState = CaptureOperatorReadScopeState ( ) ;
773+ if ( scopeState . Unavailable )
774+ {
775+ var queryGeneration = _sessionQueries . ConnectionGeneration ;
776+ cancellationToken . ThrowIfCancellationRequested ( ) ;
777+ if ( ! IsOperatorReadScopeUnavailable ( scopeState . Generation ) )
778+ {
779+ throw new OperationCanceledException (
780+ "sessions.list query belongs to a stale connection generation." ,
781+ cancellationToken ) ;
782+ }
783+
784+ // The query was not executed, so report no paging/search mode while
785+ // retaining the requested search identity for upper consumers.
786+ return new SessionQuerySnapshot
787+ {
788+ Search = string . IsNullOrWhiteSpace ( query . Search ) ? null : query . Search . Trim ( ) ,
789+ ConnectionGeneration = queryGeneration ,
790+ Sessions = Array . Empty < SessionInfo > ( ) ,
791+ MaterializedSessions = Array . Empty < SessionInfo > ( ) ,
792+ } ;
793+ }
794+
795+ EnsureCurrentSessionListGeneration ( scopeState . Generation , cancellationToken ) ;
796+ try
797+ {
798+ return await ( string . IsNullOrWhiteSpace ( query . Search )
799+ ? _sessionQueries . LoadRecentAsync ( query , cancellationToken )
800+ : _sessionQueries . SearchAsync ( query , cancellationToken ) ) . ConfigureAwait ( false ) ;
801+ }
802+ catch ( GatewayRequestException ex ) when ( IsMissingScopeError ( ex . Message , "operator.read" ) )
803+ {
804+ if ( ! MarkOperatorReadScopeUnavailable ( scopeState . Generation ) )
805+ {
806+ throw new OperationCanceledException (
807+ "sessions.list response belongs to a stale connection generation." ,
808+ ex ,
809+ cancellationToken ) ;
810+ }
811+ throw ;
812+ }
813+ }
814+
815+ private ( int Generation , bool Unavailable ) CaptureOperatorReadScopeState ( )
816+ {
817+ lock ( _sessionListCapabilityLock )
818+ return ( _sessionListConnectionGeneration , _operatorReadScopeUnavailable ) ;
819+ }
820+
821+ private bool IsOperatorReadScopeUnavailable ( int connectionGeneration )
822+ {
823+ lock ( _sessionListCapabilityLock )
824+ {
825+ return _sessionListConnectionGeneration == connectionGeneration &&
826+ _operatorReadScopeUnavailable ;
827+ }
828+ }
829+
830+ private bool MarkOperatorReadScopeUnavailable (
831+ int connectionGeneration ,
832+ string warning = "Gateway token lacks operator.read; disabling sessions polling" )
833+ {
834+ var shouldLog = false ;
835+ lock ( _sessionListCapabilityLock )
836+ {
837+ if ( _sessionListConnectionGeneration != connectionGeneration )
838+ return false ;
839+ if ( _operatorReadScopeUnavailable )
840+ return true ;
841+ _operatorReadScopeUnavailable = true ;
842+ shouldLog = true ;
843+ }
844+ if ( shouldLog )
845+ _logger . Warn ( warning ) ;
846+ return true ;
774847 }
775848
776849 public SessionQuerySnapshot ClearSessionSearch ( SessionQuery ? query = null ) =>
@@ -787,7 +860,7 @@ public async Task SubscribeSessionEventsAsync()
787860 /// <summary>Request usage/context info from gateway (may not be supported on all gateways).</summary>
788861 public async Task RequestUsageAsync ( )
789862 {
790- if ( _operatorReadScopeUnavailable ) return ;
863+ if ( CaptureOperatorReadScopeState ( ) . Unavailable ) return ;
791864 if ( ! IsConnected ) return ;
792865 try
793866 {
@@ -812,7 +885,7 @@ public async Task RequestUsageAsync()
812885 /// <summary>Request connected node inventory from gateway.</summary>
813886 public async Task RequestNodesAsync ( )
814887 {
815- if ( _operatorReadScopeUnavailable ) return ;
888+ if ( CaptureOperatorReadScopeState ( ) . Unavailable ) return ;
816889 if ( _nodeListUnsupported ) return ;
817890 await SendTrackedRequestAsync ( "node.list" ) ;
818891 }
@@ -1724,26 +1797,36 @@ private static string SerializeRequest(string requestId, string method, object?
17241797
17251798 private void TrackPendingRequest ( string requestId , string method )
17261799 {
1800+ var connectionGeneration = CaptureOperatorReadScopeState ( ) . Generation ;
17271801 lock ( _pendingRequestLock )
17281802 {
17291803 _pendingRequestMethods [ requestId ] = method ;
1804+ _pendingRequestConnectionGenerations [ requestId ] = connectionGeneration ;
17301805 }
17311806 }
17321807
17331808 private void RemovePendingRequest ( string requestId )
17341809 {
17351810 lock ( _pendingRequestLock )
1811+ {
17361812 _pendingRequestMethods . Remove ( requestId ) ;
1813+ _pendingRequestConnectionGenerations . Remove ( requestId ) ;
1814+ }
17371815 }
17381816
1739- private string ? TakePendingRequestMethod ( string ? requestId )
1817+ private ( string ? Method , int ? ConnectionGeneration ) TakePendingRequest ( string ? requestId )
17401818 {
1741- if ( string . IsNullOrWhiteSpace ( requestId ) ) return null ;
1819+ if ( string . IsNullOrWhiteSpace ( requestId ) ) return ( null , null ) ;
17421820 lock ( _pendingRequestLock )
17431821 {
17441822 if ( ! _pendingRequestMethods . Remove ( requestId , out var method ) )
1745- return null ;
1746- return method ;
1823+ return ( null , null ) ;
1824+ var generation = _pendingRequestConnectionGenerations . Remove (
1825+ requestId ,
1826+ out var connectionGeneration )
1827+ ? connectionGeneration
1828+ : ( int ? ) null ;
1829+ return ( method , generation ) ;
17471830 }
17481831 }
17491832
@@ -1752,6 +1835,7 @@ private void ClearPendingRequests()
17521835 lock ( _pendingRequestLock )
17531836 {
17541837 _pendingRequestMethods . Clear ( ) ;
1838+ _pendingRequestConnectionGenerations . Clear ( ) ;
17551839 }
17561840
17571841 lock ( _pendingChatSendLock )
@@ -1862,11 +1946,12 @@ private void ProcessMessage(string json)
18621946 private void HandleResponse ( JsonElement root )
18631947 {
18641948 string ? requestMethod = null ;
1949+ int ? requestConnectionGeneration = null ;
18651950 string ? requestId = null ;
18661951 if ( root . TryGetProperty ( "id" , out var idProp ) )
18671952 {
18681953 requestId = idProp . GetString ( ) ;
1869- requestMethod = TakePendingRequestMethod ( requestId ) ;
1954+ ( requestMethod , requestConnectionGeneration ) = TakePendingRequest ( requestId ) ;
18701955 }
18711956
18721957 var pendingChatSend = TakePendingChatSend ( requestId ) ;
@@ -1940,7 +2025,7 @@ private void HandleResponse(JsonElement root)
19402025 if ( root . TryGetProperty ( "ok" , out var okProp ) &&
19412026 okProp . ValueKind == JsonValueKind . False )
19422027 {
1943- HandleRequestError ( requestMethod , root ) ;
2028+ HandleRequestError ( requestMethod , requestConnectionGeneration , root ) ;
19442029 return ;
19452030 }
19462031
@@ -2231,7 +2316,10 @@ JsonValueKind.Object when value.TryGetProperty("message", out var message) => Tr
22312316 _ => null
22322317 } ;
22332318
2234- private void HandleRequestError ( string ? method , JsonElement root )
2319+ private void HandleRequestError (
2320+ string ? method ,
2321+ int ? requestConnectionGeneration ,
2322+ JsonElement root )
22352323 {
22362324 var message = TryGetErrorMessage ( root ) ?? "request failed" ;
22372325 var detailCode = method == "connect" ? TryGetErrorDetailCode ( root ) : null ;
@@ -2322,12 +2410,12 @@ private void HandleRequestError(string? method, JsonElement root)
23222410 if ( IsMissingScopeError ( message , "operator.read" ) &&
23232411 method is "sessions.list" or "usage.status" or "usage.cost" or "node.list" )
23242412 {
2325- if ( ! _operatorReadScopeUnavailable )
2413+ if ( requestConnectionGeneration . HasValue )
23262414 {
2327- _logger . Warn ( "Gateway token lacks operator.read; disabling sessions/usage/nodes polling" ) ;
2415+ MarkOperatorReadScopeUnavailable (
2416+ requestConnectionGeneration . Value ,
2417+ "Gateway token lacks operator.read; disabling sessions/usage/nodes polling" ) ;
23282418 }
2329-
2330- _operatorReadScopeUnavailable = true ;
23312419 return ;
23322420 }
23332421
0 commit comments