Release v0.51.344 — Release LH (sidebar fork-lineage grouping #3799/#3884) (#3893)
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user