Release v0.51.344 — Release LH (sidebar fork-lineage grouping #3799/#3884) (#3893)
Some checks failed
Release & Docker / release (push) Has been cancelled

* Release v0.51.344 — Release LH (sidebar fork-lineage grouping #3799/#3884)

Absorbs #3884 (@rodboev): manual forks are kept as sidebar lineage boundaries
so a forked session isn't collapsed under a compression-continuation root,
while enriched child-session rows stay independently visible until the later
attachment pass. Also addresses the greptile TOCTOU flag: the background
index-rebuild thread now pins + re-checks its (SESSION_DIR, SESSION_INDEX_FILE)
target under _SESSION_INDEX_REBUILD_LOCK before writing.

Rebased onto fresh master, content byte-identical to PR head, full-suite +
Codex + Opus gated.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(models): propagate target kwargs in index-rebuild fallback (Opus SHOULD-FIX)

Opus advisor stage-344: the _write_session_index fast-path fallback recursed
with _write_session_index(updates=None) and no kwargs, falling back to the
global SESSION_DIR. Safe today (the only kwargs-caller passes updates=None and
never reaches the fast path) but the invariant was implicit. Propagate the
resolved session_dir/session_index_file so a target-scoped rebuild falls back
to that same target.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-09 13:37:59 -07:00
committed by GitHub
parent c89468212b
commit 1cc8772179
3 changed files with 190 additions and 17 deletions

View File

@@ -59,6 +59,7 @@ _STALE_TMP_AGE_SECONDS = 3600 # 1 hour
_INDEX_WRITE_LOCK = threading.RLock()
_SESSION_INDEX_REBUILD_LOCK = threading.Lock()
_SESSION_INDEX_REBUILD_THREAD = None
_SESSION_INDEX_REBUILD_THREAD_TARGET: tuple[Path, Path] | None = None
# Path-safety contract for session IDs. Accept alphanumerics, underscore, and
# hyphen so API/gateway-issued ids (``api-*``, ``reachy-voice-*``) round-trip
@@ -146,26 +147,46 @@ def _session_dir_has_persisted_session_files() -> bool:
return False
def _rebuild_session_index_background() -> None:
def _rebuild_session_index_background(expected_session_dir: Path, expected_index_file: Path) -> None:
global _SESSION_INDEX_REBUILD_THREAD, _SESSION_INDEX_REBUILD_THREAD_TARGET
try:
_write_session_index(updates=None)
with _SESSION_INDEX_REBUILD_LOCK:
if SESSION_DIR != expected_session_dir or SESSION_INDEX_FILE != expected_index_file:
return
_write_session_index(
updates=None,
session_dir=expected_session_dir,
session_index_file=expected_index_file,
)
except Exception:
logger.debug("Background session-index rebuild failed", exc_info=True)
finally:
with _SESSION_INDEX_REBUILD_LOCK:
if _SESSION_INDEX_REBUILD_THREAD_TARGET == (
expected_session_dir,
expected_index_file,
):
_SESSION_INDEX_REBUILD_THREAD = None
_SESSION_INDEX_REBUILD_THREAD_TARGET = None
def _start_session_index_rebuild_thread() -> None:
"""Start one background full-index rebuild if the index is missing."""
global _SESSION_INDEX_REBUILD_THREAD
global _SESSION_INDEX_REBUILD_THREAD, _SESSION_INDEX_REBUILD_THREAD_TARGET
target = (SESSION_DIR, SESSION_INDEX_FILE)
with _SESSION_INDEX_REBUILD_LOCK:
if SESSION_INDEX_FILE.exists():
return
if (
_SESSION_INDEX_REBUILD_THREAD is not None
and _SESSION_INDEX_REBUILD_THREAD.is_alive()
and _SESSION_INDEX_REBUILD_THREAD_TARGET == target
):
return
_SESSION_INDEX_REBUILD_THREAD_TARGET = target
_SESSION_INDEX_REBUILD_THREAD = threading.Thread(
target=_rebuild_session_index_background,
args=target,
name="session-index-rebuild",
daemon=True,
)
@@ -191,7 +212,7 @@ def _index_entry_exists(session_id: str, in_memory_ids=None) -> bool:
return p.exists()
def _write_session_index(updates=None):
def _write_session_index(updates=None, *, session_dir: Path | None = None, session_index_file: Path | None = None):
"""Update the session index file.
When *updates* is provided (a list of Session objects whose compact
@@ -202,18 +223,20 @@ def _write_session_index(updates=None):
LOCK protects in-memory state snapshots and payload construction only;
disk I/O (write/flush/fsync/replace) always runs outside LOCK.
"""
_tmp = SESSION_INDEX_FILE.with_suffix(f'.tmp.{os.getpid()}.{threading.current_thread().ident}')
session_dir = session_dir or SESSION_DIR
session_index_file = session_index_file or SESSION_INDEX_FILE
_tmp = session_index_file.with_suffix(f'.tmp.{os.getpid()}.{threading.current_thread().ident}')
with _INDEX_WRITE_LOCK:
# Lazy full-rebuild path — used when index doesn't exist yet.
if updates is None or not SESSION_INDEX_FILE.exists():
if updates is None or not session_index_file.exists():
_cleanup_stale_tmp_files() # best-effort sweep on startup / first call
entry_map: dict[str, dict] = {}
for p in SESSION_DIR.glob('*.json'):
for p in session_dir.glob('*.json'):
if p.name.startswith('_'):
continue
try:
s = Session.load(p.stem)
s = _load_session_from_path(p)
if s:
c = s.compact()
sid = c.get('session_id')
@@ -243,7 +266,7 @@ def _write_session_index(updates=None):
f.write(_payload)
f.flush()
os.fsync(f.fileno())
os.replace(_tmp, SESSION_INDEX_FILE)
os.replace(_tmp, session_index_file)
except Exception:
# Best-effort cleanup of stale tmp on failure
try:
@@ -261,7 +284,7 @@ def _write_session_index(updates=None):
# on-disk IDs once before entering the critical section.
on_disk_ids = _persisted_session_ids_snapshot()
with LOCK:
existing = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
existing = json.loads(session_index_file.read_text(encoding='utf-8'))
in_memory_ids = set(SESSIONS.keys())
existing = [
@@ -289,7 +312,7 @@ def _write_session_index(updates=None):
f.write(_payload)
f.flush()
os.fsync(f.fileno())
os.replace(_tmp, SESSION_INDEX_FILE)
os.replace(_tmp, session_index_file)
except Exception:
try:
_tmp.unlink(missing_ok=True)
@@ -300,8 +323,16 @@ def _write_session_index(updates=None):
_fallback = True
if _fallback:
# Corrupt or missing index — fall back to full rebuild (called outside LOCK to avoid deadlock)
_write_session_index(updates=None)
# Corrupt or missing index — fall back to full rebuild (called outside LOCK to avoid deadlock).
# Propagate the resolved target so a rebuild scoped to a specific session dir
# (the background rebuild thread) falls back to rebuilding THAT dir's index,
# not the global SESSION_DIR (Opus advisor, stage-344 — defensive; today the
# only kwargs-caller passes updates=None and never reaches the fast path).
_write_session_index(
updates=None,
session_dir=session_dir,
session_index_file=session_index_file,
)
def prune_session_from_index(session_id: str) -> None:
@@ -517,6 +548,16 @@ def _read_metadata_json_prefix(path, max_prefix_bytes=65536):
return None
def _load_session_from_path(path: Path) -> "Session | None":
"""Load a session from an explicit JSON path without consulting SESSION_DIR."""
try:
data = json.loads(path.read_text(encoding='utf-8'))
except Exception:
return None
data['messages'], _collapsed_partials = _collapse_adjacent_duplicate_partials(data.get('messages'))
return Session(**data)
def _lookup_index_message_count(session_id):
"""Return the indexed message count without loading the full session file."""
return _index_message_count_map().get(str(session_id))
@@ -2458,9 +2499,18 @@ def _sidebar_message_count(session: dict) -> int:
def _sidebar_lineage_root_id(session: dict, sessions_by_id: dict[str, dict]) -> str:
sid = str(session.get('session_id') or '')
explicit = str(session.get('_lineage_root_id') or '').strip()
if explicit:
return explicit
relationship_type = str(session.get('relationship_type') or '').strip().lower()
if relationship_type == 'child_session':
return sid
root = sid
parent = session.get('parent_session_id')
source = str(session.get('session_source') or '').strip().lower()
seen = {sid}
if source == 'fork':
return root
while parent and parent not in seen and parent in sessions_by_id:
root = str(parent)
seen.add(root)
@@ -3134,6 +3184,8 @@ def all_sessions(diag=None):
and not s.get('has_pending_user_message')
and not s.get('worktree_path')
)]
_diag_stage(diag, "all_sessions.lineage_metadata")
_enrich_sidebar_lineage_metadata(result)
result = _prefer_fuller_snapshots_for_sidebar(result)
sidebar_candidates = result
visible_result = [s for s in sidebar_candidates if not _hide_from_default_sidebar(s)]
@@ -3145,8 +3197,6 @@ def all_sessions(diag=None):
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
_diag_stage(diag, "all_sessions.lineage_metadata")
_enrich_sidebar_lineage_metadata(result)
return result
except Exception:
logger.debug("Failed to load session index, falling back to full scan")
@@ -3175,6 +3225,8 @@ def all_sessions(diag=None):
and not s.pending_user_message
and not getattr(s, 'worktree_path', None)
)]
_diag_stage(diag, "all_sessions.lineage_metadata")
_enrich_sidebar_lineage_metadata(result)
result = _prefer_fuller_snapshots_for_sidebar(result)
sidebar_candidates = result
visible_result = [s for s in sidebar_candidates if not _hide_from_default_sidebar(s)]
@@ -3184,8 +3236,6 @@ def all_sessions(diag=None):
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
_diag_stage(diag, "all_sessions.lineage_metadata")
_enrich_sidebar_lineage_metadata(result)
return result