cctally 1.80.2 → 1.80.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.80.4] - 2026-07-23
9
+
10
+ ### Fixed
11
+ - Codex Recent Sessions now keeps MCP-invoked sessions in true last-activity order and derives each Started time and duration from retained accounting events, so a cache rebuild no longer gives historical conversations one shared date or `0m` durations.
12
+
13
+ ## [1.80.3] - 2026-07-23
14
+
15
+ ### Fixed
16
+ - Cache recovery now detects a damaged `session_entries` B-tree even when `cache.db` still has a readable schema, allowing dashboard startup and `cache-sync --rebuild` to quarantine and recreate the already-corrupt v1.80.1/v1.80.2 cache instead of surfacing a persistent sync error.
17
+
8
18
  ## [1.80.2] - 2026-07-23
9
19
 
10
20
  ### Fixed
@@ -4812,6 +4812,19 @@ def _cache_open_guarded() -> sqlite3.Connection:
4812
4812
  # Force a real header/schema read. SELECT 1 is constant-folded and can
4813
4813
  # report success without touching a malformed database file.
4814
4814
  conn.execute("PRAGMA schema_version").fetchone()
4815
+ # The v1.80.2 production incident left the schema and left edge of
4816
+ # session_entries readable while its interior root page pointed past
4817
+ # EOF on the right. Probe that right-most path explicitly: O(log N), so
4818
+ # every short-lived hook can afford it, unlike PRAGMA quick_check's
4819
+ # whole-database scan.
4820
+ if conn.execute(
4821
+ "SELECT 1 FROM sqlite_schema "
4822
+ "WHERE type='table' AND name='session_entries'"
4823
+ ).fetchone() is not None:
4824
+ conn.execute(
4825
+ "SELECT rowid FROM session_entries "
4826
+ "ORDER BY rowid DESC LIMIT 1"
4827
+ ).fetchone()
4815
4828
  if marker.exists():
4816
4829
  conn.close()
4817
4830
  conn = None
@@ -757,12 +757,19 @@ def _codex_conversation_metadata(
757
757
  metadata: dict[tuple[str, str], dict[str, object]] = {}
758
758
  try:
759
759
  core_rows = tuple(cache_conn.execute(
760
+ "WITH accounting AS ("
761
+ " SELECT source_root_key, source_path, MIN(id) AS first_id,"
762
+ " MIN(timestamp_utc) AS started_at"
763
+ " FROM codex_session_entries"
764
+ " GROUP BY source_root_key, source_path"
765
+ ") "
760
766
  "SELECT t.source_root_key, t.source_path, t.native_thread_id, "
761
- "(SELECT e.session_id FROM codex_session_entries AS e "
762
- " WHERE e.source_root_key=t.source_root_key AND e.source_path=t.source_path "
763
- " ORDER BY e.id LIMIT 1) AS accounting_session_id, "
764
- "t.cwd, t.git_json, t.first_seen_utc, t.last_seen_utc "
767
+ "e.session_id AS accounting_session_id, "
768
+ "t.cwd, t.git_json, a.started_at, t.last_seen_utc "
765
769
  "FROM codex_conversation_threads AS t "
770
+ "LEFT JOIN accounting AS a "
771
+ "ON a.source_root_key=t.source_root_key AND a.source_path=t.source_path "
772
+ "LEFT JOIN codex_session_entries AS e ON e.id=a.first_id "
766
773
  "ORDER BY t.last_seen_utc DESC, t.conversation_key DESC"
767
774
  ))
768
775
  from _cctally_cache import _codex_conversation_project_attribution
@@ -778,16 +785,20 @@ def _codex_conversation_metadata(
778
785
  ) in core_rows
779
786
  )
780
787
  file_aliases = tuple(cache_conn.execute(
781
- "SELECT source_root_key, path, last_native_thread_id, last_session_id "
782
- "FROM codex_session_files "
783
- "WHERE last_native_thread_id IS NOT NULL AND last_native_thread_id != '' "
784
- "ORDER BY last_ingested_at DESC, path DESC"
788
+ "SELECT f.source_root_key, f.path, f.last_native_thread_id, "
789
+ "f.last_session_id, MIN(e.timestamp_utc) "
790
+ "FROM codex_session_files AS f "
791
+ "LEFT JOIN codex_session_entries AS e "
792
+ "ON e.source_root_key=f.source_root_key AND e.source_path=f.path "
793
+ "WHERE f.last_native_thread_id IS NOT NULL AND f.last_native_thread_id != '' "
794
+ "GROUP BY f.source_root_key, f.path, f.last_native_thread_id, f.last_session_id "
795
+ "ORDER BY f.last_ingested_at DESC, f.path DESC"
785
796
  ))
786
797
  native_ids = tuple(sorted({
787
798
  str(native_thread_id) for _, _, native_thread_id, *_ in rows
788
799
  if isinstance(native_thread_id, str) and native_thread_id
789
800
  } | {
790
- str(native_thread_id) for _, _, native_thread_id, _ in file_aliases
801
+ str(native_thread_id) for _, _, native_thread_id, *_ in file_aliases
791
802
  if isinstance(native_thread_id, str) and native_thread_id
792
803
  }))
793
804
  provider_roots = {
@@ -858,7 +869,10 @@ def _codex_conversation_metadata(
858
869
  # persists the rooted native thread id. Inherit only presentation
859
870
  # metadata from that rooted task; the child's accounting path and
860
871
  # session id remain its own identity and totals are never merged.
861
- for root_key, source_path, native_thread_id, accounting_session_id in file_aliases:
872
+ for (
873
+ root_key, source_path, native_thread_id, accounting_session_id,
874
+ started_at,
875
+ ) in file_aliases:
862
876
  identity = (str(root_key or ""), str(source_path or ""))
863
877
  if not all(identity) or identity in metadata:
864
878
  continue
@@ -871,7 +885,7 @@ def _codex_conversation_metadata(
871
885
  "root_path": str(provider_roots.get(identity[0]) or ""),
872
886
  "project_key": (inherited or {}).get("project_key"),
873
887
  "project_label": (inherited or {}).get("project_label"),
874
- "started_at": (inherited or {}).get("started_at"),
888
+ "started_at": started_at or (inherited or {}).get("started_at"),
875
889
  }
876
890
  except sqlite3.Error:
877
891
  return {}