@@ -37,11 +37,14 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
3737 function heartbeatDeps (
3838 sendTelegram : ReturnType < typeof vi . fn > ,
3939 replySpy : HeartbeatReplySpy ,
40+ now ?: number ,
4041 ) : HeartbeatDeps {
4142 return {
4243 telegram : sendTelegram as unknown ,
4344 getQueueSize : ( ) => 0 ,
44- nowMs : ( ) => Date . now ( ) ,
45+ // A fixed clock lets a test seed pendingFinalDeliveryCreatedAt relative to
46+ // the run's startedAt, which is what the ownership guard compares against.
47+ nowMs : ( ) => now ?? Date . now ( ) ,
4548 getReplyFromConfig : replySpy ,
4649 } satisfies HeartbeatDeps ;
4750 }
@@ -78,23 +81,25 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
7881 it ( "nulls every pendingFinalDelivery* field after delivering substantive heartbeat content" , async ( ) => {
7982 await withTempHeartbeatSandbox ( async ( { storePath, replySpy } ) => {
8083 const cfg = createHeartbeatConfig ( storePath ) ;
84+ const NOW = Date . now ( ) ;
8185
82- // Seed a session that carries a stuck pendingFinalDelivery from a prior run.
83- // pendingFinalDeliveryText is a heartbeat-ack token so the pendingFinalDelivery
84- // gate at heartbeat-runner.ts:~1390 does not bail before the send.
86+ // Seed a stuck pendingFinalDelivery this run owns: createdAt at run start
87+ // marks it as produced by this heartbeat (the case the original fix
88+ // targets). pendingFinalDeliveryText is a heartbeat-ack token so the
89+ // pendingFinalDelivery defer gate does not bail before the send.
8590 const sessionKey = await seedMainSessionStore ( storePath , cfg , {
8691 lastChannel : "telegram" ,
8792 lastProvider : "telegram" ,
8893 lastTo : TELEGRAM_GROUP ,
89- updatedAt : Date . now ( ) ,
94+ updatedAt : NOW ,
9095 pendingFinalDelivery : true ,
9196 pendingFinalDeliveryText : "HEARTBEAT_OK" ,
92- pendingFinalDeliveryCreatedAt : 1 ,
97+ pendingFinalDeliveryCreatedAt : NOW ,
9398 pendingFinalDeliveryAttemptCount : 3 ,
9499 pendingFinalDeliveryLastError : "prior-error" ,
95100 } ) ;
96101 await patchEntry ( storePath , sessionKey , {
97- pendingFinalDeliveryLastAttemptAt : 2 ,
102+ pendingFinalDeliveryLastAttemptAt : NOW ,
98103 pendingFinalDeliveryContext : { foo : "bar" } ,
99104 pendingFinalDeliveryIntentId : "intent-send-success" ,
100105 } ) ;
@@ -105,7 +110,10 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
105110 replySpy . mockResolvedValue ( { text : replyText } ) ;
106111 const sendTelegram = vi . fn ( ) . mockResolvedValue ( { messageId : "m1" , toJid : "jid" } ) ;
107112
108- const result = await runHeartbeatOnce ( { cfg, deps : heartbeatDeps ( sendTelegram , replySpy ) } ) ;
113+ const result = await runHeartbeatOnce ( {
114+ cfg,
115+ deps : heartbeatDeps ( sendTelegram , replySpy , NOW ) ,
116+ } ) ;
109117
110118 expect ( result . status ) . toBe ( "ran" ) ;
111119 expect ( sendTelegram ) . toHaveBeenCalledTimes ( 1 ) ;
@@ -136,10 +144,11 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
136144
137145 const body = "Heartbeat update: everything is green." ;
138146 const deliveredText = `🤖 ${ body } ` ;
147+ const NOW = Date . now ( ) ;
139148 // updatedAt is well past the 30s defer window so the pendingFinalDelivery
140149 // gate does not bail before the duplicate branch (the pending text is
141150 // substantive, not a heartbeat ack).
142- const staleAt = Date . now ( ) - 60_000 ;
151+ const staleAt = NOW - 60_000 ;
143152
144153 const sessionKey = await seedMainSessionStore ( storePath , cfg , {
145154 lastChannel : "telegram" ,
@@ -148,7 +157,9 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
148157 updatedAt : staleAt ,
149158 pendingFinalDelivery : true ,
150159 pendingFinalDeliveryText : body , // prefix-less; diverges from deliveredText
151- pendingFinalDeliveryCreatedAt : 1 ,
160+ // createdAt at run start: this run produced the pending (real runs stamp
161+ // a fresh createdAt during the agent turn the defer gate ran ahead of).
162+ pendingFinalDeliveryCreatedAt : NOW ,
152163 pendingFinalDeliveryAttemptCount : 3 ,
153164 pendingFinalDeliveryLastError : "prior-error" ,
154165 } ) ;
@@ -157,7 +168,7 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
157168 // which is what makes this run a duplicate and the pending clear safe.
158169 lastHeartbeatText : deliveredText ,
159170 lastHeartbeatSentAt : staleAt ,
160- pendingFinalDeliveryLastAttemptAt : 2 ,
171+ pendingFinalDeliveryLastAttemptAt : NOW ,
161172 pendingFinalDeliveryContext : { foo : "bar" } ,
162173 pendingFinalDeliveryIntentId : "intent-duplicate-skip" ,
163174 } ) ;
@@ -167,7 +178,10 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
167178 replySpy . mockResolvedValue ( { text : body } ) ;
168179 const sendTelegram = vi . fn ( ) . mockResolvedValue ( { messageId : "m1" , toJid : "jid" } ) ;
169180
170- const result = await runHeartbeatOnce ( { cfg, deps : heartbeatDeps ( sendTelegram , replySpy ) } ) ;
181+ const result = await runHeartbeatOnce ( {
182+ cfg,
183+ deps : heartbeatDeps ( sendTelegram , replySpy , NOW ) ,
184+ } ) ;
171185
172186 // status "ran" (not "skipped") proves the run reached the duplicate branch
173187 // rather than bailing at the pendingFinalDelivery gate.
@@ -181,4 +195,104 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
181195 expectPendingFinalDeliveryCleared ( entry ) ;
182196 } ) ;
183197 } ) ;
198+
199+ it ( "preserves an older unsatisfied pendingFinalDelivery the heartbeat send did not create" , async ( ) => {
200+ await withTempHeartbeatSandbox ( async ( { storePath, replySpy } ) => {
201+ const cfg = createHeartbeatConfig ( storePath ) ;
202+ const NOW = Date . now ( ) ;
203+ // An older user-facing final that failed delivery and still owns its own
204+ // get-reply redelivery recovery path. It predates this run (createdAt <
205+ // startedAt) and a message_tool_only/response-tool heartbeat would not
206+ // refresh it, so the send-success clear must NOT retire it.
207+ const olderCreatedAt = NOW - 60_000 ;
208+ const olderText = "Older final the user never received" ;
209+ const sessionKey = await seedMainSessionStore ( storePath , cfg , {
210+ lastChannel : "telegram" ,
211+ lastProvider : "telegram" ,
212+ lastTo : TELEGRAM_GROUP ,
213+ // Stale so the substantive-pending defer gate does not bail this run.
214+ updatedAt : NOW - 60_000 ,
215+ pendingFinalDelivery : true ,
216+ pendingFinalDeliveryText : olderText ,
217+ pendingFinalDeliveryCreatedAt : olderCreatedAt ,
218+ pendingFinalDeliveryAttemptCount : 2 ,
219+ pendingFinalDeliveryLastError : "prior-delivery-failure" ,
220+ } ) ;
221+ await patchEntry ( storePath , sessionKey , {
222+ pendingFinalDeliveryLastAttemptAt : NOW - 50_000 ,
223+ pendingFinalDeliveryContext : { channel : "telegram" , to : "older-chat" } ,
224+ pendingFinalDeliveryIntentId : "intent-older-unsatisfied" ,
225+ } ) ;
226+
227+ // A fresh, different heartbeat payload that gets delivered this run.
228+ const replyText = "Fresh heartbeat content unrelated to the older final." ;
229+ replySpy . mockResolvedValue ( { text : replyText } ) ;
230+ const sendTelegram = vi . fn ( ) . mockResolvedValue ( { messageId : "m1" , toJid : "jid" } ) ;
231+
232+ const result = await runHeartbeatOnce ( {
233+ cfg,
234+ deps : heartbeatDeps ( sendTelegram , replySpy , NOW ) ,
235+ } ) ;
236+
237+ expect ( result . status ) . toBe ( "ran" ) ;
238+ expect ( sendTelegram ) . toHaveBeenCalledTimes ( 1 ) ;
239+
240+ const entry = await readEntry ( storePath , sessionKey ) ;
241+ // Send-success records the dedupe markers for the delivered payload...
242+ expect ( entry ?. lastHeartbeatText ) . toBe ( replyText ) ;
243+ // ...but the older, unowned pending-final survives for its own recovery.
244+ expect ( entry ?. pendingFinalDelivery ) . toBe ( true ) ;
245+ expect ( entry ?. pendingFinalDeliveryText ) . toBe ( olderText ) ;
246+ expect ( entry ?. pendingFinalDeliveryCreatedAt ) . toBe ( olderCreatedAt ) ;
247+ expect ( entry ?. pendingFinalDeliveryIntentId ) . toBe ( "intent-older-unsatisfied" ) ;
248+ } ) ;
249+ } ) ;
250+
251+ it ( "preserves an older unowned pendingFinalDelivery on a duplicate skip" , async ( ) => {
252+ await withTempHeartbeatSandbox ( async ( { storePath, replySpy } ) => {
253+ const cfg = createHeartbeatConfig ( storePath ) ;
254+ const NOW = Date . now ( ) ;
255+ const body = "Recurring heartbeat status line." ;
256+ const olderCreatedAt = NOW - 60_000 ;
257+ const olderText = "A different older final still awaiting delivery" ;
258+ const sessionKey = await seedMainSessionStore ( storePath , cfg , {
259+ lastChannel : "telegram" ,
260+ lastProvider : "telegram" ,
261+ lastTo : TELEGRAM_GROUP ,
262+ updatedAt : NOW - 60_000 ,
263+ pendingFinalDelivery : true ,
264+ pendingFinalDeliveryText : olderText ,
265+ pendingFinalDeliveryCreatedAt : olderCreatedAt ,
266+ pendingFinalDeliveryAttemptCount : 2 ,
267+ pendingFinalDeliveryLastError : "prior-delivery-failure" ,
268+ } ) ;
269+ await patchEntry ( storePath , sessionKey , {
270+ // Same payload already delivered within 24h -> this run is a duplicate skip.
271+ lastHeartbeatText : body ,
272+ lastHeartbeatSentAt : NOW - 60_000 ,
273+ pendingFinalDeliveryLastAttemptAt : NOW - 50_000 ,
274+ pendingFinalDeliveryContext : { channel : "telegram" , to : "older-chat" } ,
275+ pendingFinalDeliveryIntentId : "intent-older-dupe" ,
276+ } ) ;
277+
278+ replySpy . mockResolvedValue ( { text : body } ) ;
279+ const sendTelegram = vi . fn ( ) . mockResolvedValue ( { messageId : "m1" , toJid : "jid" } ) ;
280+
281+ const result = await runHeartbeatOnce ( {
282+ cfg,
283+ deps : heartbeatDeps ( sendTelegram , replySpy , NOW ) ,
284+ } ) ;
285+
286+ expect ( result . status ) . toBe ( "ran" ) ;
287+ // Duplicate payload: nothing is sent this run.
288+ expect ( sendTelegram ) . not . toHaveBeenCalled ( ) ;
289+
290+ const entry = await readEntry ( storePath , sessionKey ) ;
291+ // The duplicate-skip clear must not retire the older, unowned pending-final.
292+ expect ( entry ?. pendingFinalDelivery ) . toBe ( true ) ;
293+ expect ( entry ?. pendingFinalDeliveryText ) . toBe ( olderText ) ;
294+ expect ( entry ?. pendingFinalDeliveryCreatedAt ) . toBe ( olderCreatedAt ) ;
295+ expect ( entry ?. pendingFinalDeliveryIntentId ) . toBe ( "intent-older-dupe" ) ;
296+ } ) ;
297+ } ) ;
184298} ) ;
0 commit comments