@@ -73,7 +73,13 @@ export interface AgentNoticeInboxSignaller {
7373 * subscription that could never be honoured.
7474 */
7575 subscribe ( principal : AgentNoticePrincipal ) : Promise < void > ;
76- unsubscribe ( ) : void ;
76+ /**
77+ * Ends the subscription. Resolves only after every observation already in
78+ * flight has settled, so once it resolves no further signal is sent or
79+ * budget spent for the connection — a `resources/unsubscribe` acknowledged
80+ * to the client is honoured even while the store is slow.
81+ */
82+ unsubscribe ( ) : Promise < void > ;
7783}
7884
7985interface InboxSubscription {
@@ -115,9 +121,21 @@ export const createNoticeInboxSignaller = (
115121 const now = options . now ?? ( ( ) : Date => new Date ( ) ) ;
116122 let subscription : InboxSubscription | undefined ;
117123 let signalSequence = 0 ;
118- // Observations serialize so two renders completing together cannot both
119- // select the same notice and send two signals for one revision.
124+ // Observations and subscription changes serialize on one queue: two renders
125+ // completing together cannot both select the same notice and send two
126+ // signals for one revision, and an unsubscribe (or re-subscribe) that
127+ // overlaps an observation awaiting the store takes effect only after that
128+ // observation settles, never between its eligibility read and its send.
120129 let queue : Promise < unknown > = Promise . resolve ( ) ;
130+ // Unsubscribes requested while an observation awaits the store are counted
131+ // synchronously so that observation yields before spending any budget: the
132+ // client asked to stop, so nothing is claimed or sent on its behalf.
133+ let pendingUnsubscribes = 0 ;
134+ const serialized = < T > ( step : ( ) => Promise < T > ) : Promise < T > => {
135+ const run = queue . then ( step ) ;
136+ queue = run . catch ( ( ) => undefined ) ;
137+ return run ;
138+ } ;
121139
122140 /**
123141 * Claims the budget for every newly eligible notice as one compare-and-swap
@@ -133,6 +151,7 @@ export const createNoticeInboxSignaller = (
133151 ) : Promise <
134152 | { readonly at : string ; readonly kind : 'claimed' ; readonly noticeIds : readonly string [ ] ; readonly revision : number }
135153 | { readonly kind : 'nothing-eligible' ; readonly revision : number }
154+ | { readonly kind : 'unsubscribed' }
136155 | { readonly error : unknown ; readonly kind : 'failed' ; readonly stage : 'read' | 'record' }
137156 > => {
138157 for ( let attempt = 0 ; attempt < MAX_CLAIM_ATTEMPTS ; attempt += 1 ) {
@@ -142,6 +161,7 @@ export const createNoticeInboxSignaller = (
142161 } catch ( error ) {
143162 return { error, kind : 'failed' , stage : 'read' } ;
144163 }
164+ if ( pendingUnsubscribes > 0 || subscription !== current ) return { kind : 'unsubscribed' } ;
145165 const at = now ( ) . toISOString ( ) ;
146166 const nowMs = Date . parse ( at ) ;
147167 const noticeIds = Object . freeze ( snapshot . notices
@@ -183,12 +203,17 @@ export const createNoticeInboxSignaller = (
183203 } catch ( error ) {
184204 return Object . freeze ( { error, kind : 'failed' as const , stage : 'read' as const } ) ;
185205 }
206+ if ( pendingUnsubscribes > 0 ) {
207+ return Object . freeze ( { kind : 'idle' , reason : 'no-subscription' , revision : undefined } ) ;
208+ }
186209 const claimed = await claim ( ledger , current ) ;
187210 switch ( claimed . kind ) {
188211 case 'failed' :
189212 return Object . freeze ( { error : claimed . error , kind : 'failed' as const , stage : claimed . stage } ) ;
190213 case 'nothing-eligible' :
191214 return Object . freeze ( { kind : 'idle' , reason : 'nothing-eligible' , revision : claimed . revision } ) ;
215+ case 'unsubscribed' :
216+ return Object . freeze ( { kind : 'idle' , reason : 'no-subscription' , revision : undefined } ) ;
192217 case 'claimed' :
193218 break ;
194219 default : {
@@ -219,21 +244,25 @@ export const createNoticeInboxSignaller = (
219244 return options . store . close ( ) ;
220245 } ,
221246 observe ( send : ( ) => Promise < void > ) : Promise < AgentNoticeInboxSignalOutcome > {
222- const run = queue . then ( ( ) => observeOnce ( send ) ) ;
223- queue = run . catch ( ( ) => undefined ) ;
224- return run ;
247+ return serialized ( ( ) => observeOnce ( send ) ) ;
225248 } ,
226- async subscribe ( principal : AgentNoticePrincipal ) : Promise < void > {
227- const ledger = await options . store . noticeLedger ( ) ;
228- await ledger . read ( ) ;
229- subscription = Object . freeze ( {
230- id : randomUUID ( ) ,
231- principal,
232- signalled : new Set < string > ( ) ,
249+ subscribe ( principal : AgentNoticePrincipal ) : Promise < void > {
250+ return serialized ( async ( ) => {
251+ const ledger = await options . store . noticeLedger ( ) ;
252+ await ledger . read ( ) ;
253+ subscription = Object . freeze ( {
254+ id : randomUUID ( ) ,
255+ principal,
256+ signalled : new Set < string > ( ) ,
257+ } ) ;
233258 } ) ;
234259 } ,
235- unsubscribe ( ) : void {
236- subscription = undefined ;
260+ unsubscribe ( ) : Promise < void > {
261+ pendingUnsubscribes += 1 ;
262+ return serialized ( async ( ) => {
263+ pendingUnsubscribes -= 1 ;
264+ subscription = undefined ;
265+ } ) ;
237266 } ,
238267 } ) ;
239268} ;
0 commit comments