@@ -122,6 +122,10 @@ def __init__(
122122 self ._client = httpx .Client (timeout = 5.0 )
123123 # Schema: {<content_hash>: {"session_id": str, "project": str}}.
124124 self ._sessions : dict [str , dict ] = {}
125+ # Diff-compare session_ids (``diff-<a>-<b>``) Buckaroo has loaded this
126+ # lifetime. Reset on a Buckaroo restart, same as ``_sessions`` — these
127+ # sessions live only in the subprocess's RAM.
128+ self ._loaded_diff_sessions : set [str ] = set ()
125129 self ._session_lock = threading .Lock ()
126130 self ._buckaroo_started_at : float | None = None
127131 # Tmp dirs we created by expanding ${TALLYMAN_PROJECT_ROOT} placeholders
@@ -224,17 +228,9 @@ def start(self) -> None:
224228 started_at = health .get ("started" )
225229 log .info ("buckaroo ready on %s (started=%s)" , self .base_url , started_at )
226230 # If this Buckaroo started fresh (different start time from
227- # what we last saw), the cached session_ids are stale and
228- # we drop them so /load runs again on first hit.
229- if started_at != self ._buckaroo_started_at :
230- if self ._sessions :
231- log .info (
232- "buckaroo restart detected; invalidating %d cached sessions" ,
233- len (self ._sessions ),
234- )
235- self ._sessions = {}
236- self ._buckaroo_started_at = started_at
237- self ._persist_sessions ()
231+ # what we last saw), its in-RAM sessions are gone — drop our
232+ # bookkeeping so /load_expr runs again on first hit.
233+ self ._reset_session_bookkeeping_if_restarted (started_at )
238234 threading .Thread (target = self ._drain_stdout , daemon = True ).start ()
239235 return
240236 except httpx .HTTPError :
@@ -282,6 +278,46 @@ def _cleanup_expanded_dirs(self) -> None:
282278 shutil .rmtree (p , ignore_errors = True )
283279 self ._expanded_dirs .clear ()
284280
281+ # ------------------------------------------------------------------
282+ # diff-compare session bookkeeping
283+ # ------------------------------------------------------------------
284+
285+ def diff_session_is_loaded (self , session_id : str ) -> bool :
286+ """True if Buckaroo has loaded this diff-compare session this lifetime."""
287+ return session_id in self ._loaded_diff_sessions
288+
289+ def mark_diff_session_loaded (self , session_id : str ) -> None :
290+ """Record a successful diff ``/load_expr``.
291+
292+ Dropped on a Buckaroo restart by ``_reset_session_bookkeeping_if_restarted``
293+ — the session exists only in the subprocess's RAM, so a stale entry would
294+ point a client at a session the restarted process never loaded.
295+ """
296+ self ._loaded_diff_sessions .add (session_id )
297+
298+ def _reset_session_bookkeeping_if_restarted (self , started_at ) -> None :
299+ """Drop in-RAM session bookkeeping when a fresh Buckaroo is detected.
300+
301+ Buckaroo's ``/load_expr`` sessions — entry (``_sessions``) and
302+ diff-compare (``_loaded_diff_sessions``) alike — live only in the
303+ subprocess's memory, so a restart (a new ``started_at`` from ``/health``)
304+ invalidates every session_id we've handed out. Clearing both forces a
305+ re-POST on next access; that reload is cheap because the on-disk
306+ stat/result cache survives the restart. Keeping a stale entry instead
307+ would hand a client a dead session.
308+ """
309+ if started_at == self ._buckaroo_started_at :
310+ return
311+ if self ._sessions :
312+ log .info (
313+ "buckaroo restart detected; invalidating %d cached sessions" ,
314+ len (self ._sessions ),
315+ )
316+ self ._sessions = {}
317+ self ._loaded_diff_sessions .clear ()
318+ self ._buckaroo_started_at = started_at
319+ self ._persist_sessions ()
320+
285321 # ------------------------------------------------------------------
286322 # session map (persisted to disk)
287323 # ------------------------------------------------------------------
@@ -486,10 +522,22 @@ def ensure_session(
486522 # Entries are content-addressed and immutable, so the expansion is
487523 # always correct once written.
488524 expanded = entry_dir (project , content_hash ) / ".xorq_build_expanded"
489- if not expanded .exists ():
490- expanded .mkdir (parents = True , exist_ok = True )
525+ expanded_marker = entry_dir (project , content_hash ) / ".xorq_build_expanded.complete"
526+ if not expanded_marker .exists ():
527+ # Re-expand whenever the completion marker is absent: either this
528+ # is the first load, or a previous expansion was interrupted
529+ # (crash/kill/OOM) and left a partial dir. The marker is written
530+ # last, so its presence is the only proof every file landed. An
531+ # `exists()` check on the dir itself would treat a truncated
532+ # expansion as complete — feeding buckaroo a build_dir missing
533+ # files (zero rows), or raising FileExistsError out of the lock
534+ # if a subdir landed half-copied — on every subsequent load.
535+ if expanded .exists ():
536+ shutil .rmtree (expanded )
537+ expanded .mkdir (parents = True )
491538 from tallyman_xorq .portable import expand_into_dir # noqa: PLC0415
492539 expand_into_dir (build_dir , project_dir (project ), expanded )
540+ expanded_marker .write_text ("" )
493541 stat_cache = entry_dir (project , content_hash ) / ".buckaroo_stat_cache"
494542 stat_cache .mkdir (parents = True , exist_ok = True )
495543 payload : dict = {
0 commit comments