mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(codex): discover updated Codex state stores (#1889)
## Description Codex Desktop can move its local thread database to a later `state_<n>.sqlite` file after an app update. Headroom already retags Codex thread providers when it enables or disables the `headroom` provider, but the helper only looked at the v148 `state_5.sqlite` locations. When Codex starts reading a newer state store, native `openai` chats stay in that newer database while Headroom switches the active provider to `headroom`, so Codex filters those chats out of the history menu. This discovers numeric Codex state stores in the two existing Codex home locations, then applies the same best-effort retagging to every discovered store. Legacy `state_5.sqlite` behavior stays intact, third-party providers remain untouched, and corrupt or schema-incompatible stores are skipped without breaking install, init, wrap, or unwrap. Closes #1853. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/providers/codex/threads.py`: discover direct numeric `state_<n>.sqlite` stores under `<codex_home>/sqlite` and `<codex_home>`, preserving deterministic ordering and existing best-effort retag behavior. - `tests/test_provider_codex_threads.py`: cover updated Codex state-store versions, multi-store retagging, adjacent non-store boundaries, corrupt and OS-error continuation, and the existing legacy `state_5.sqlite` path. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_provider_codex_threads.py -q`) - [x] Linting passes (`uv run ruff check headroom/providers/codex/threads.py tests/test_provider_codex_threads.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_provider_codex_threads.py -q ======================== 10 passed, 1 warning in 0.32s ======================== uv run ruff check headroom/providers/codex/threads.py tests/test_provider_codex_threads.py All checks passed! ``` ## Real Behavior Proof - Environment: local SQLite fixtures. - Exact command / steps: Seed a Codex home with `<codex_home>/sqlite/state_6.sqlite` containing native `openai` thread rows, call `retag_to_headroom(codex_home)`, and inspect the `threads.model_provider` counts. - Observed result: the updated state store is discovered and matching rows move to `headroom`; third-party provider rows remain unchanged. The same helper still retags legacy `state_5.sqlite` stores and skips corrupt, inaccessible, or missing stores without raising. - Not tested: live Codex Desktop UI after an update; the proof exercises the same SQLite provider tags that Codex filters its history menu by. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes Documentation and changelog updates are N/A for this narrow repair to existing Codex history retagging behavior. Install, init, wrap, and unwrap keep using the shared provider helper without new call-site branches.
This commit is contained in:
parent
e36439a941
commit
9d42ebaa1a
2 changed files with 151 additions and 9 deletions
|
|
@ -22,6 +22,7 @@ busy timeout only covers a transient checkpoint lock.
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -33,15 +34,34 @@ NATIVE_PROVIDER = "openai"
|
|||
# Seconds to wait on a busy store before giving up (a running Codex only holds an
|
||||
# exclusive lock briefly, during a WAL checkpoint).
|
||||
_BUSY_TIMEOUT_S = 0.75
|
||||
_STATE_DB_RE = re.compile(r"^state_(\d+)\.sqlite$")
|
||||
|
||||
|
||||
def _codex_state_db_paths(codex_home: Path) -> list[Path]:
|
||||
"""Both known Codex state stores under ``codex_home`` (the ``.codex`` dir).
|
||||
|
||||
The v148 desktop GUI reads ``<codex_home>/sqlite/state_5.sqlite``; the
|
||||
CLI/TUI uses ``<codex_home>/state_5.sqlite``. Retag whichever exist.
|
||||
"""
|
||||
return [codex_home / "sqlite" / "state_5.sqlite", codex_home / "state_5.sqlite"]
|
||||
"""Discover direct Codex state stores under the known home locations."""
|
||||
discovered: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for base in (codex_home / "sqlite", codex_home):
|
||||
if not base.exists():
|
||||
continue
|
||||
matches: list[tuple[int, Path]] = []
|
||||
try:
|
||||
entries = list(base.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for path in entries:
|
||||
match = _STATE_DB_RE.match(path.name)
|
||||
if match is None or not path.is_file():
|
||||
continue
|
||||
matches.append((int(match.group(1)), path))
|
||||
matches.sort(key=lambda item: (item[0], str(item[1])))
|
||||
for _, path in matches:
|
||||
resolved = path.resolve()
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
discovered.append(path)
|
||||
return discovered
|
||||
|
||||
|
||||
def _retag_one(path: Path, *, frm: str, to: str) -> int:
|
||||
|
|
@ -76,11 +96,9 @@ def retag_thread_providers(codex_home: Path, *, frm: str, to: str) -> None:
|
|||
if frm == to:
|
||||
return
|
||||
for path in _codex_state_db_paths(codex_home):
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
moved = _retag_one(path, frm=frm, to=to)
|
||||
except sqlite3.Error as exc:
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
logger.warning("codex thread retag %s->%s skipped for %s: %s", frm, to, path, exc)
|
||||
continue
|
||||
if moved:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from __future__ import annotations
|
|||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.providers.codex import threads
|
||||
|
||||
|
||||
|
|
@ -27,6 +29,62 @@ def _count(path: Path, provider: str) -> int:
|
|||
conn.close()
|
||||
|
||||
|
||||
def test_codex_state_db_paths_discovers_numeric_files_in_directory_order(tmp_path: Path) -> None:
|
||||
sqlite_home = tmp_path / "sqlite"
|
||||
sqlite_home.mkdir()
|
||||
nested = sqlite_home / "nested"
|
||||
nested.mkdir()
|
||||
|
||||
_seed(sqlite_home / "state_10.sqlite", [("a", "openai")])
|
||||
_seed(sqlite_home / "state_2.sqlite", [("b", "openai")])
|
||||
_seed(sqlite_home / "state_backup.sqlite", [("c", "openai")])
|
||||
(sqlite_home / "state_6.sqlite-wal").write_text("wal", encoding="utf-8")
|
||||
_seed(nested / "state_7.sqlite", [("d", "openai")])
|
||||
_seed(tmp_path / "state_9.sqlite", [("e", "openai")])
|
||||
_seed(tmp_path / "state_1.sqlite", [("f", "openai")])
|
||||
(tmp_path / "other.db").write_text("other", encoding="utf-8")
|
||||
|
||||
assert threads._codex_state_db_paths(tmp_path) == [
|
||||
sqlite_home / "state_2.sqlite",
|
||||
sqlite_home / "state_10.sqlite",
|
||||
tmp_path / "state_1.sqlite",
|
||||
tmp_path / "state_9.sqlite",
|
||||
]
|
||||
|
||||
|
||||
def test_retag_thread_providers_discovers_later_state_store_and_skips_adjacent_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sqlite_home = tmp_path / "sqlite"
|
||||
sqlite_home.mkdir()
|
||||
nested = sqlite_home / "nested"
|
||||
nested.mkdir()
|
||||
|
||||
later = sqlite_home / "state_6.sqlite"
|
||||
legacy = tmp_path / "state_5.sqlite"
|
||||
backup = sqlite_home / "state_backup.sqlite"
|
||||
sidecar = sqlite_home / "state_6.sqlite-wal"
|
||||
nested_store = nested / "state_7.sqlite"
|
||||
|
||||
_seed(later, [("a", "openai"), ("b", "openai"), ("c", "anthropic")])
|
||||
_seed(legacy, [("d", "openai"), ("e", "headroom")])
|
||||
_seed(backup, [("f", "openai")])
|
||||
sidecar.write_text("wal", encoding="utf-8")
|
||||
_seed(nested_store, [("g", "openai")])
|
||||
|
||||
threads.retag_to_headroom(tmp_path)
|
||||
|
||||
assert _count(later, "headroom") == 2
|
||||
assert _count(later, "openai") == 0
|
||||
assert _count(later, "anthropic") == 1
|
||||
assert _count(legacy, "headroom") == 2
|
||||
assert _count(legacy, "openai") == 0
|
||||
assert _count(backup, "openai") == 1
|
||||
assert _count(backup, "headroom") == 0
|
||||
assert _count(nested_store, "openai") == 1
|
||||
assert _count(nested_store, "headroom") == 0
|
||||
|
||||
|
||||
def test_retag_one_moves_only_matching_provider(tmp_path: Path) -> None:
|
||||
db = tmp_path / "state_5.sqlite"
|
||||
_seed(db, [("a", "openai"), ("b", "openai"), ("c", "headroom"), ("d", "anthropic")])
|
||||
|
|
@ -56,6 +114,29 @@ def test_retag_thread_providers_silent_when_no_store(tmp_path: Path) -> None:
|
|||
threads.retag_thread_providers(tmp_path, frm="openai", to="headroom")
|
||||
|
||||
|
||||
def test_retag_thread_providers_skips_unreadable_store_directory(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
sqlite_home = tmp_path / "sqlite"
|
||||
sqlite_home.mkdir()
|
||||
_seed(sqlite_home / "state_6.sqlite", [("a", "openai")])
|
||||
fallback = tmp_path / "state_5.sqlite"
|
||||
_seed(fallback, [("b", "openai")])
|
||||
original_iterdir = Path.iterdir
|
||||
|
||||
def fail_for_sqlite(path: Path):
|
||||
if path == sqlite_home:
|
||||
raise OSError("permission denied")
|
||||
return original_iterdir(path)
|
||||
|
||||
monkeypatch.setattr(Path, "iterdir", fail_for_sqlite)
|
||||
|
||||
threads.retag_to_headroom(tmp_path)
|
||||
|
||||
assert _count(fallback, "headroom") == 1
|
||||
assert _count(sqlite_home / "state_6.sqlite", "openai") == 1
|
||||
|
||||
|
||||
def test_retag_thread_providers_best_effort_on_corrupt_store(tmp_path: Path) -> None:
|
||||
bad = tmp_path / "state_5.sqlite"
|
||||
bad.write_text("not a sqlite database", encoding="utf-8")
|
||||
|
|
@ -63,6 +144,49 @@ def test_retag_thread_providers_best_effort_on_corrupt_store(tmp_path: Path) ->
|
|||
threads.retag_thread_providers(tmp_path, frm="openai", to="headroom")
|
||||
|
||||
|
||||
def test_retag_thread_providers_skips_corrupt_discovered_store_and_continues(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sqlite_home = tmp_path / "sqlite"
|
||||
sqlite_home.mkdir()
|
||||
|
||||
bad = sqlite_home / "state_6.sqlite"
|
||||
later = sqlite_home / "state_7.sqlite"
|
||||
bad.write_text("not a sqlite database", encoding="utf-8")
|
||||
_seed(later, [("a", "openai"), ("b", "anthropic")])
|
||||
|
||||
threads.retag_to_headroom(tmp_path)
|
||||
|
||||
assert _count(later, "headroom") == 1
|
||||
assert _count(later, "openai") == 0
|
||||
assert _count(later, "anthropic") == 1
|
||||
|
||||
|
||||
def test_retag_thread_providers_skips_os_error_store_and_continues(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
sqlite_home = tmp_path / "sqlite"
|
||||
sqlite_home.mkdir()
|
||||
locked = sqlite_home / "state_6.sqlite"
|
||||
later = sqlite_home / "state_7.sqlite"
|
||||
_seed(locked, [("a", "openai")])
|
||||
_seed(later, [("b", "openai"), ("c", "anthropic")])
|
||||
original_retag_one = threads._retag_one
|
||||
|
||||
def fail_for_locked(path: Path, *, frm: str, to: str) -> int:
|
||||
if path == locked:
|
||||
raise OSError("locked")
|
||||
return original_retag_one(path, frm=frm, to=to)
|
||||
|
||||
monkeypatch.setattr(threads, "_retag_one", fail_for_locked)
|
||||
|
||||
threads.retag_to_headroom(tmp_path)
|
||||
|
||||
assert _count(locked, "openai") == 1
|
||||
assert _count(later, "headroom") == 1
|
||||
assert _count(later, "anthropic") == 1
|
||||
|
||||
|
||||
def test_enable_disable_wrappers_retag_expected_direction(tmp_path: Path) -> None:
|
||||
db = tmp_path / "state_5.sqlite"
|
||||
_seed(db, [("a", "openai"), ("b", "headroom")])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue