@@ -28,6 +28,9 @@ function toVisibleDecryptedMessages(messages: StoredMessageForDelivery[]): Decry
2828}
2929
3030export class MessageService {
31+ /** One scheduled-matured SSE per localId per hub process (cleared on cancel/consume paths here). */
32+ private readonly scheduledMatureNotifiedLocalIds = new Set < string > ( )
33+
3134 constructor (
3235 private readonly store : Store ,
3336 private readonly io : Server ,
@@ -36,6 +39,12 @@ export class MessageService {
3639 ) {
3740 }
3841
42+ private forgetScheduledMatureNotified ( localIds : Iterable < string > ) : void {
43+ for ( const localId of localIds ) {
44+ this . scheduledMatureNotifiedLocalIds . delete ( localId )
45+ }
46+ }
47+
3948 getMessages ( sessionId : string , limit : number = 200 ) : DecryptedMessage [ ] {
4049 const stored = this . store . messages . getMessages ( sessionId , limit )
4150 return toVisibleDecryptedMessages ( stored )
@@ -196,6 +205,7 @@ export class MessageService {
196205 const now = Date . now ( )
197206 if ( scheduledAt !== null && scheduledAt > now ) {
198207 this . store . messages . deleteQueuedMessageById ( sessionId , resolvedId )
208+ this . forgetScheduledMatureNotified ( [ localId ] )
199209 this . publisher . emit ( {
200210 type : 'message-cancelled' ,
201211 sessionId,
@@ -224,6 +234,7 @@ export class MessageService {
224234 const recheck = this . store . messages . lookupQueuedMessage ( sessionId , resolvedId )
225235 if ( recheck . status === 'invoked' ) {
226236 // CLI beat us — treat identically to Race-B (ack returned not-found).
237+ this . forgetScheduledMatureNotified ( [ localId ] )
227238 this . publisher . emit ( {
228239 type : 'messages-consumed' ,
229240 sessionId,
@@ -233,6 +244,7 @@ export class MessageService {
233244 return recheck
234245 }
235246 // Row is gone (absent) — clean cancel.
247+ this . forgetScheduledMatureNotified ( [ localId ] )
236248 this . publisher . emit ( {
237249 type : 'message-cancelled' ,
238250 sessionId,
@@ -257,6 +269,7 @@ export class MessageService {
257269 // DB write failed — let the HTTP 500 surface to the caller.
258270 throw err
259271 }
272+ this . forgetScheduledMatureNotified ( [ localId ] )
260273 // Notify all SSE subscribers (other open tabs) that this queued row is now
261274 // invoked so they remove it from the floating bar. Without this emit, only
262275 // the tab that sent the DELETE request learns about the status change via the
@@ -282,6 +295,7 @@ export class MessageService {
282295
283296 // Phase 3: CLI confirmed removal. Now DELETE the DB row and broadcast SSE.
284297 this . store . messages . deleteQueuedMessageById ( sessionId , resolvedId )
298+ this . forgetScheduledMatureNotified ( [ localId ] )
285299 this . publisher . emit ( {
286300 type : 'message-cancelled' ,
287301 sessionId,
@@ -455,6 +469,7 @@ export class MessageService {
455469 . filter ( ( id ) : id is string => typeof id === 'string' )
456470 if ( localIds . length === 0 ) return null
457471 this . store . messages . markMessagesInvoked ( sessionId , localIds , invokedAt )
472+ this . forgetScheduledMatureNotified ( localIds )
458473 this . publisher . emit ( { type : 'messages-consumed' , sessionId, localIds, invokedAt } )
459474 return { localIds, invokedAt }
460475 }
@@ -476,7 +491,13 @@ export class MessageService {
476491 * expected behaviour. */
477492 releaseMatureScheduledMessages ( now : number ) : void {
478493 const mature = this . store . messages . getMatureScheduledMessages ( now )
494+ const maturedSessionIds = new Set < string > ( )
479495 for ( const msg of mature ) {
496+ const localId = msg . localId
497+ if ( typeof localId === 'string' && ! this . scheduledMatureNotifiedLocalIds . has ( localId ) ) {
498+ this . scheduledMatureNotifiedLocalIds . add ( localId )
499+ maturedSessionIds . add ( msg . sessionId )
500+ }
480501 const update = {
481502 id : msg . id ,
482503 seq : msg . seq ,
@@ -497,5 +518,8 @@ export class MessageService {
497518 // NOTE: do NOT call markMessagesInvoked here (pitfall #2).
498519 // CLI ack (messages-consumed) will handle invoked_at stamping.
499520 }
521+ for ( const sessionId of maturedSessionIds ) {
522+ this . publisher . emit ( { type : 'scheduled-matured' , sessionId } )
523+ }
500524 }
501525}
0 commit comments