@@ -142,6 +142,10 @@ def __init__(
142142 # Serialize wrapper mutations against compaction snapshot/replace/restore so a
143143 # cancellation rollback cannot rewrite past a newer concurrent write.
144144 self ._mutation_lock = asyncio .Lock ()
145+ # Runner persistence can carry this wrapper-local generation across the
146+ # append-to-compaction gap. A later wrapper mutation revokes that one
147+ # pending automatic replacement without inferring ownership from history.
148+ self ._mutation_generation = 0
145149
146150 @property
147151 def client (self ) -> AsyncOpenAI :
@@ -178,6 +182,35 @@ async def run_compaction(
178182 When a run context is provided, the billed compaction request contributes to
179183 that run's usage totals.
180184 """
185+ # Keep one wrapper mutation boundary from the snapshot through replacement.
186+ # A concurrent add, pop, or clear waits here and then runs against the
187+ # compacted state instead of being overwritten by a stale replacement.
188+ async with self ._mutation_lock :
189+ has_expected_generation = wrapper is not None and hasattr (
190+ wrapper , "_session_compaction_generation"
191+ )
192+ expected_generation = (
193+ getattr (wrapper , "_session_compaction_generation" , None )
194+ if has_expected_generation
195+ else None
196+ )
197+ if has_expected_generation and (
198+ not isinstance (expected_generation , int )
199+ or expected_generation != self ._mutation_generation
200+ ):
201+ logger .warning (
202+ "Skipped compaction because Session history changed after this "
203+ "run appended its items."
204+ )
205+ return
206+ await self ._run_compaction_locked (args , wrapper = wrapper )
207+
208+ async def _run_compaction_locked (
209+ self ,
210+ args : OpenAIResponsesCompactionArgs | None ,
211+ * ,
212+ wrapper : RunContextWrapper [Any ] | None ,
213+ ) -> None :
181214 if args and args .get ("response_id" ):
182215 self ._response_id = args ["response_id" ]
183216 requested_mode = args .get ("compaction_mode" ) if args else None
@@ -245,14 +278,18 @@ async def run_compaction(
245278 _normalize_compaction_output_items (compacted .output or [])
246279 )
247280
248- async with self ._mutation_lock :
249- previous_items = await self . _get_all_underlying_session_items ()
281+ previous_items = await self ._get_all_underlying_session_items ()
282+ try :
250283 await self ._replace_underlying_session_items (
251284 output_items = output_items ,
252285 previous_items = previous_items ,
253286 )
254- self ._compaction_candidate_items = select_compaction_candidate_items (output_items )
255- self ._session_items = output_items
287+ except (Exception , asyncio .CancelledError ):
288+ self ._mutation_generation += 1
289+ raise
290+ self ._mutation_generation += 1
291+ self ._compaction_candidate_items = select_compaction_candidate_items (output_items )
292+ self ._session_items = output_items
256293
257294 logger .debug (
258295 "compact: done for %s (mode=%s, output=%s, candidates=%s)" ,
@@ -265,6 +302,14 @@ async def run_compaction(
265302 async def get_items (self , limit : int | None = None ) -> list [TResponseInputItem ]:
266303 return await self .underlying_session .get_items (limit )
267304
305+ async def _get_items_with_generation (
306+ self , limit : int | None = None
307+ ) -> tuple [list [TResponseInputItem ], int ]:
308+ """Read one Runner snapshot with its exact wrapper generation."""
309+ async with self ._mutation_lock :
310+ items = await self .underlying_session .get_items (limit )
311+ return items , self ._mutation_generation
312+
268313 async def _get_all_underlying_session_items (self ) -> list [TResponseInputItem ]:
269314 return await self .underlying_session .get_items (limit = _ALL_SESSION_ITEMS_LIMIT )
270315
@@ -410,37 +455,69 @@ def _clear_deferred_compaction(self) -> None:
410455 self ._deferred_response_id = None
411456
412457 async def add_items (self , items : list [TResponseInputItem ]) -> None :
458+ async with self ._mutation_lock :
459+ await self ._add_items_locked (items )
460+
461+ async def _add_items_with_generation (
462+ self ,
463+ items : list [TResponseInputItem ],
464+ * ,
465+ expected_generation : int | None ,
466+ ) -> int | None :
467+ """Append one Runner batch and retain ownership only when its read stayed current."""
468+ async with self ._mutation_lock :
469+ owns_generation = expected_generation == self ._mutation_generation
470+ await self ._add_items_locked (items )
471+ return self ._mutation_generation if owns_generation else None
472+
473+ async def _add_items_locked (self , items : list [TResponseInputItem ]) -> None :
474+ try :
475+ await self .underlying_session .add_items (items )
476+ except (Exception , asyncio .CancelledError ):
477+ # The backend may have committed before acknowledgement failed. Re-read its
478+ # authoritative history before compaction instead of retaining a stale cache.
479+ self ._compaction_candidate_items = None
480+ self ._session_items = None
481+ self ._mutation_generation += 1
482+ raise
483+ self ._mutation_generation += 1
484+ if self ._compaction_candidate_items is not None :
485+ new_items = _normalize_compaction_session_items (items )
486+ new_candidates = select_compaction_candidate_items (new_items )
487+ if new_candidates :
488+ self ._compaction_candidate_items .extend (new_candidates )
489+ if self ._session_items is not None :
490+ self ._session_items .extend (_normalize_compaction_session_items (items ))
491+
492+ async def pop_item (self ) -> TResponseInputItem | None :
413493 async with self ._mutation_lock :
414494 try :
415- await self .underlying_session .add_items ( items )
495+ popped = await self .underlying_session .pop_item ( )
416496 except (Exception , asyncio .CancelledError ):
417- # The backend may have committed before acknowledgement failed. Re-read its
418- # authoritative history before compaction instead of retaining a stale cache.
419497 self ._compaction_candidate_items = None
420498 self ._session_items = None
499+ self ._mutation_generation += 1
421500 raise
422- if self ._compaction_candidate_items is not None :
423- new_items = _normalize_compaction_session_items (items )
424- new_candidates = select_compaction_candidate_items (new_items )
425- if new_candidates :
426- self ._compaction_candidate_items .extend (new_candidates )
427- if self ._session_items is not None :
428- self ._session_items .extend (_normalize_compaction_session_items (items ))
429-
430- async def pop_item (self ) -> TResponseInputItem | None :
431- async with self ._mutation_lock :
432- popped = await self .underlying_session .pop_item ()
433501 if popped :
434502 self ._compaction_candidate_items = None
435503 self ._session_items = None
504+ self ._mutation_generation += 1
436505 return popped
437506
438507 async def clear_session (self ) -> None :
439508 async with self ._mutation_lock :
440- await self .underlying_session .clear_session ()
509+ try :
510+ await self .underlying_session .clear_session ()
511+ except (Exception , asyncio .CancelledError ):
512+ self ._compaction_candidate_items = None
513+ self ._session_items = None
514+ self ._deferred_response_id = None
515+ self ._mutation_generation += 1
516+ raise
441517 self ._compaction_candidate_items = []
442518 self ._session_items = []
443519 self ._deferred_response_id = None
520+ self ._mutation_generation += 1
444521
445522 async def _ensure_compaction_candidates (
446523 self ,
0 commit comments