fix(agents): open agy, codex and hermes databases read-only (Issue #64)
This commit is contained in:
1 parent
3ce5d5fdc1
commit
b0eccc5301
8 files changed
+461
-56
No files matched your search
@@ -0,0 +1,408 @@
|
||||
"""Issue #64: read-only SQLite connections (`_ro_connect`) for agy, codex and
|
||||
hermes conversation/state databases across adapters, status.sh and reconcile.sh.
|
||||
|
||||
1. `_ro_connect` lives in `lib_py.agents.base` and `opencode` reuses it.
|
||||
2. Read-only queries on `agy` (`conversations/<uuid>.db`), `codex`
|
||||
(`state_*.sqlite`), `hermes` (`state.db`), and `db_rows_naming` return the
|
||||
committed rows and leave the directory listing untouched across all four WAL
|
||||
states (`clean`, `leftover`, `walonly`, `writer`).
|
||||
3. `status.sh` (JSON and text modes) and `reconcile.sh` (drift C for agy,
|
||||
hermes, codex) open agent DBs via `_ro_connect` and never create or remove
|
||||
`-wal` / `-shm` sidecars, including on another workspace's `agy` DB when an
|
||||
unpinned `agy` session triggers drift-C discovery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS = REPO_ROOT / ".agents" / "skills"
|
||||
if str(SKILLS) not in sys.path:
|
||||
sys.path.insert(0, str(SKILLS))
|
||||
|
||||
from lib_py.agents.base import DiscoveryContext, db_rows_naming # noqa: E402
|
||||
from lib_py.agents.registry import get_adapter # noqa: E402
|
||||
from lib_py.verify_session import verify_session_uuid # noqa: E402
|
||||
|
||||
|
||||
def _make_wal_state(db: Path, state: str, setup_fn, tmp_root: Path, opened: list) -> list:
|
||||
"""Build `db` in one of four WAL states (`clean`, `leftover`, `walonly`,
|
||||
`writer`) using `setup_fn(conn)` to create tables and insert rows, then
|
||||
return `sorted(os.listdir(db.parent))`."""
|
||||
db.parent.mkdir(parents=True, exist_ok=True)
|
||||
for name in list(os.listdir(db.parent)):
|
||||
if name.startswith(db.name):
|
||||
os.remove(db.parent / name)
|
||||
|
||||
def _open_and_seed(target: Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(target))
|
||||
conn.execute("PRAGMA journal_mode=wal")
|
||||
setup_fn(conn)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
if state == "clean":
|
||||
_open_and_seed(db).close()
|
||||
# macOS persistent WAL keeps -wal and -shm on close; remove them after
|
||||
# the close checkpointed the rows into the main DB file.
|
||||
for suffix in ("-wal", "-shm"):
|
||||
p = Path(str(db) + suffix)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
elif state in ("leftover", "walonly"):
|
||||
src = tmp_root / f"src-{state}-{db.name}"
|
||||
if src.parent != tmp_root:
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = _open_and_seed(src)
|
||||
suffixes = ("", "-wal", "-shm") if state == "leftover" else ("", "-wal")
|
||||
for suffix in suffixes:
|
||||
shutil.copyfile(str(src) + suffix, str(db) + suffix)
|
||||
conn.close()
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
p = Path(str(src) + suffix)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
elif state == "writer":
|
||||
opened.append(_open_and_seed(db))
|
||||
else:
|
||||
raise ValueError(f"unknown state: {state}")
|
||||
|
||||
return sorted(os.listdir(db.parent))
|
||||
|
||||
|
||||
def _expected_listing(db_name: str, state: str, extra: list | None = None) -> list:
|
||||
base = [db_name]
|
||||
if state in ("leftover", "writer"):
|
||||
base += [f"{db_name}-shm", f"{db_name}-wal"]
|
||||
elif state == "walonly":
|
||||
base += [f"{db_name}-wal"]
|
||||
if extra:
|
||||
base += extra
|
||||
return sorted(base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Shared helper in lib_py.agents.base
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ro_connect_lives_in_base_and_opencode_reuses_it():
|
||||
from lib_py.agents import base as base_mod
|
||||
from lib_py.agents.adapters import opencode as opencode_mod
|
||||
|
||||
assert hasattr(base_mod, "_ro_connect"), "_ro_connect must be defined in lib_py.agents.base"
|
||||
assert opencode_mod._ro_connect is base_mod._ro_connect
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Adapter queries across all four WAL states (agy, codex, hermes, base)
|
||||
# ---------------------------------------------------------------------------
|
||||
STATES = ("clean", "leftover", "walonly", "writer")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", STATES)
|
||||
@pytest.mark.parametrize("query", ["verify_artifact", "discover"])
|
||||
def test_agy_queries_read_right_and_leave_sidecars_alone(tmp_path, state, query):
|
||||
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
uuid = "a6400001-0000-4000-8000-000000000001"
|
||||
db = home / ".gemini" / "antigravity-cli" / "conversations" / f"{uuid}.db"
|
||||
lc = home / ".gemini" / "antigravity-cli" / "cache" / "last_conversations.json"
|
||||
lc.parent.mkdir(parents=True, exist_ok=True)
|
||||
lc.write_text(json.dumps({str(ws): uuid}))
|
||||
|
||||
opened = []
|
||||
try:
|
||||
listing = _make_wal_state(
|
||||
db, state,
|
||||
lambda c: (c.execute("CREATE TABLE steps (id INTEGER)"),
|
||||
c.execute("INSERT INTO steps VALUES (1)")),
|
||||
tmp_path, opened,
|
||||
)
|
||||
assert listing == _expected_listing(db.name, state)
|
||||
|
||||
adapter = get_adapter("agy")
|
||||
ctx = DiscoveryContext(workspace=str(ws), agent_name="agy", home_dir=str(home))
|
||||
if query == "verify_artifact":
|
||||
assert adapter.verify_artifact(uuid, ctx) is True
|
||||
else:
|
||||
assert adapter.discover(ctx) == [uuid]
|
||||
|
||||
assert sorted(os.listdir(db.parent)) == listing
|
||||
finally:
|
||||
for c in opened:
|
||||
c.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", STATES)
|
||||
@pytest.mark.parametrize("query", ["_find_rollout_via_db", "verify_artifact", "discover", "residual_artifacts", "db_rows_naming"])
|
||||
def test_codex_and_db_rows_naming_queries_read_right_and_leave_sidecars_alone(tmp_path, state, query):
|
||||
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
uuid = "c6400001-0000-4000-8000-000000000001"
|
||||
codex_home = home / ".codex"
|
||||
rollout = codex_home / "sessions" / "2026" / "10" / "19" / f"rollout-001-{uuid}.jsonl"
|
||||
rollout.parent.mkdir(parents=True, exist_ok=True)
|
||||
rollout.write_text(json.dumps({"payload": {"id": uuid, "cwd": str(ws)}}) + "\n")
|
||||
db = codex_home / "state_5.sqlite"
|
||||
|
||||
opened = []
|
||||
try:
|
||||
listing = _make_wal_state(
|
||||
db, state,
|
||||
lambda c: (
|
||||
c.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT, updated_at INTEGER)"),
|
||||
c.execute("INSERT INTO threads VALUES (?, ?, ?, 2000000)", (uuid, str(ws), str(rollout))),
|
||||
),
|
||||
tmp_path, opened,
|
||||
)
|
||||
expected = _expected_listing(db.name, state, extra=["sessions"])
|
||||
assert listing == expected
|
||||
|
||||
adapter = get_adapter("codex")
|
||||
ctx = DiscoveryContext(workspace=str(ws), agent_name="codex", home_dir=str(home))
|
||||
if query == "_find_rollout_via_db":
|
||||
assert adapter._find_rollout_via_db(uuid, ctx) == (str(rollout), str(ws))
|
||||
elif query == "verify_artifact":
|
||||
assert adapter.verify_artifact(uuid, ctx) is True
|
||||
elif query == "discover":
|
||||
assert adapter.discover(ctx) == [uuid]
|
||||
elif query == "residual_artifacts":
|
||||
res = adapter.residual_artifacts(uuid, ctx)
|
||||
assert any("threads.id: 1 rows" in r for r in res), res
|
||||
elif query == "db_rows_naming":
|
||||
res = db_rows_naming(str(db), uuid, ("thread_id",), id_tables=(("threads", "id"),))
|
||||
assert res == [f"{db} (threads.id: 1 rows)"]
|
||||
|
||||
assert sorted(os.listdir(db.parent)) == expected
|
||||
finally:
|
||||
for c in opened:
|
||||
c.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", STATES)
|
||||
@pytest.mark.parametrize("query", ["verify_artifact", "discover"])
|
||||
def test_hermes_queries_read_right_and_leave_sidecars_alone(tmp_path, state, query):
|
||||
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
uuid = "h6400001-0000-4000-8000-000000000001"
|
||||
db = home / ".hermes" / "state.db"
|
||||
|
||||
opened = []
|
||||
try:
|
||||
listing = _make_wal_state(
|
||||
db, state,
|
||||
lambda c: (
|
||||
c.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY, cwd TEXT, started_at REAL)"),
|
||||
c.execute("INSERT INTO sessions VALUES (?, ?, 2000000.0)", (uuid, str(ws))),
|
||||
),
|
||||
tmp_path, opened,
|
||||
)
|
||||
assert listing == _expected_listing(db.name, state)
|
||||
|
||||
adapter = get_adapter("hermes")
|
||||
ctx = DiscoveryContext(workspace=str(ws), agent_name="hermes", home_dir=str(home), epoch=1500000)
|
||||
if query == "verify_artifact":
|
||||
assert adapter.verify_artifact(uuid, ctx) is True
|
||||
else:
|
||||
assert adapter.discover(ctx) == [uuid]
|
||||
|
||||
assert sorted(os.listdir(db.parent)) == listing
|
||||
finally:
|
||||
for c in opened:
|
||||
c.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Static + runtime checks for status.sh and reconcile.sh
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_status_and_reconcile_static_ro_connect_usage():
|
||||
status_text = (SKILLS / "multi-agent-mux-status" / "scripts" / "status.sh").read_text()
|
||||
assert status_text.count("_ro_connect(hdb)") == 2
|
||||
assert status_text.count("_ro_connect(odb)") == 2
|
||||
assert status_text.count("_ro_connect(db)") == 2
|
||||
assert "sqlite3.connect" not in status_text
|
||||
|
||||
reconcile_text = (SKILLS / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh").read_text()
|
||||
assert reconcile_text.count("_ro_connect(hdb)") == 1
|
||||
assert reconcile_text.count("_ro_connect(odb)") == 1
|
||||
assert reconcile_text.count("_ro_connect(state_db)") == 1
|
||||
assert "sqlite3.connect" not in reconcile_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", STATES)
|
||||
def test_status_and_reconcile_runtime_leave_sidecars_alone(mam_sandbox, mock_herdr, state):
|
||||
home = mam_sandbox / "isolated_home"
|
||||
ws = mam_sandbox / "ws"
|
||||
ws.mkdir(exist_ok=True)
|
||||
|
||||
u_agy = "a6400002-0000-4000-8000-000000000002"
|
||||
u_hermes = "h6400002-0000-4000-8000-000000000002"
|
||||
u_codex = "c6400002-0000-4000-8000-000000000002"
|
||||
|
||||
agy_db = home / ".gemini" / "antigravity-cli" / "conversations" / f"{u_agy}.db"
|
||||
lc = home / ".gemini" / "antigravity-cli" / "cache" / "last_conversations.json"
|
||||
lc.parent.mkdir(parents=True, exist_ok=True)
|
||||
lc.write_text(json.dumps({str(ws): u_agy}))
|
||||
|
||||
hermes_db = home / ".hermes" / "state.db"
|
||||
|
||||
codex_home = home / ".codex"
|
||||
rollout = codex_home / "sessions" / "2026" / "10" / "19" / f"rollout-001-{u_codex}.jsonl"
|
||||
rollout.parent.mkdir(parents=True, exist_ok=True)
|
||||
rollout.write_text(json.dumps({"payload": {"id": u_codex, "cwd": str(ws)}}) + "\n")
|
||||
codex_db = codex_home / "state_5.sqlite"
|
||||
|
||||
opened = []
|
||||
try:
|
||||
agy_list = _make_wal_state(
|
||||
agy_db, state,
|
||||
lambda c: (c.execute("CREATE TABLE steps (id INTEGER)"), c.execute("INSERT INTO steps VALUES (1)")),
|
||||
mam_sandbox, opened,
|
||||
)
|
||||
hermes_list = _make_wal_state(
|
||||
hermes_db, state,
|
||||
lambda c: (
|
||||
c.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY, cwd TEXT, started_at REAL)"),
|
||||
c.execute("INSERT INTO sessions VALUES (?, ?, 2000000000.0)", (u_hermes, str(ws))),
|
||||
),
|
||||
mam_sandbox, opened,
|
||||
)
|
||||
codex_list = _make_wal_state(
|
||||
codex_db, state,
|
||||
lambda c: (
|
||||
c.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT, updated_at INTEGER)"),
|
||||
c.execute("INSERT INTO threads VALUES (?, ?, ?, 2000000000)", (u_codex, str(ws), str(rollout))),
|
||||
),
|
||||
mam_sandbox, opened,
|
||||
)
|
||||
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
reconcile_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
status_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
lib_sh = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"HOME": str(home),
|
||||
"HOME_DIR": str(home),
|
||||
"CODEX_HOME": str(codex_home),
|
||||
"WORKSPACE_ROOT": str(mam_sandbox),
|
||||
"AGENT_SESSIONS_YAML": str(yaml_path),
|
||||
}
|
||||
|
||||
# 1) Unpinned running sessions -> reconcile.sh --once --emit-diff --dry-run (drift C queries)
|
||||
mutate_unpinned = f"""
|
||||
d['herdr_sessions'] = [
|
||||
{{'name': 's-creator-agy', 'status': 'running', 'role': 'creator', 'herdr_session_epoch': 1000000001,
|
||||
'agy_conversation_id_own': None, 'pane': {{'cwd': {str(ws)!r}, 'cmd': 'agy'}}}},
|
||||
{{'name': 's-creator-hermes', 'status': 'running', 'role': 'creator', 'herdr_session_epoch': 1000000001,
|
||||
'hermes_conversation_id_own': None, 'pane': {{'cwd': {str(ws)!r}, 'cmd': 'hermes'}}}},
|
||||
{{'name': 's-creator-codex', 'status': 'running', 'role': 'creator', 'herdr_session_epoch': 1000000001,
|
||||
'codex_session_id_own': None, 'pane': {{'cwd': {str(ws)!r}, 'cmd': 'codex'}}}},
|
||||
]
|
||||
"""
|
||||
subprocess.run(
|
||||
["bash", "-c", f"source '{lib_sh}' && atomic_dump_yaml '{yaml_path}'"],
|
||||
input=mutate_unpinned, text=True, env=env, check=True, capture_output=True,
|
||||
)
|
||||
res_rec = subprocess.run(
|
||||
["bash", str(reconcile_sh), "--once", "--emit-diff", "--dry-run"],
|
||||
env=env, capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert res_rec.returncode == 0, res_rec.stderr
|
||||
assert sorted(os.listdir(agy_db.parent)) == agy_list
|
||||
assert sorted(os.listdir(hermes_db.parent)) == hermes_list
|
||||
assert sorted(os.listdir(codex_db.parent)) == codex_list
|
||||
|
||||
# 2) Pinned sessions -> status.sh --json and text mode (resume_on_disk queries)
|
||||
mutate_pinned = f"""
|
||||
d['herdr_sessions'] = [
|
||||
{{'name': 's-creator-agy', 'status': 'stopped', 'role': 'creator',
|
||||
'agy_conversation_id_own': {u_agy!r}, 'pane': {{'cwd': {str(ws)!r}, 'cmd': 'agy'}}}},
|
||||
{{'name': 's-creator-hermes', 'status': 'stopped', 'role': 'creator',
|
||||
'hermes_conversation_id_own': {u_hermes!r}, 'pane': {{'cwd': {str(ws)!r}, 'cmd': 'hermes'}}}},
|
||||
{{'name': 's-creator-codex', 'status': 'stopped', 'role': 'creator',
|
||||
'codex_session_id_own': {u_codex!r}, 'pane': {{'cwd': {str(ws)!r}, 'cmd': 'codex'}}}},
|
||||
]
|
||||
"""
|
||||
subprocess.run(
|
||||
["bash", "-c", f"source '{lib_sh}' && atomic_dump_yaml '{yaml_path}'"],
|
||||
input=mutate_pinned, text=True, env=env, check=True, capture_output=True,
|
||||
)
|
||||
res_json = subprocess.run(
|
||||
["bash", str(status_sh), "--json"],
|
||||
env=env, capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert res_json.returncode == 0, res_json.stderr
|
||||
details = {row["name"]: row["resume_state"] for row in json.loads(res_json.stdout)["sessions_detail"]}
|
||||
assert details == {
|
||||
"s-creator-agy": "yes",
|
||||
"s-creator-hermes": "yes",
|
||||
"s-creator-codex": "yes",
|
||||
}
|
||||
|
||||
res_txt = subprocess.run(
|
||||
["bash", str(status_sh)],
|
||||
env=env, capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert res_txt.returncode == 0, res_txt.stderr
|
||||
for sname in ("s-creator-agy", "s-creator-hermes", "s-creator-codex"):
|
||||
(line,) = [l for l in res_txt.stdout.splitlines() if l.startswith(sname)]
|
||||
assert " yes " in line, line
|
||||
|
||||
assert sorted(os.listdir(agy_db.parent)) == agy_list
|
||||
assert sorted(os.listdir(hermes_db.parent)) == hermes_list
|
||||
assert sorted(os.listdir(codex_db.parent)) == codex_list
|
||||
finally:
|
||||
for c in opened:
|
||||
c.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Cross-workspace agy DB sidecar regression test (Task 1 root-cause)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars(tmp_path):
|
||||
"""When workspace A has an unpinned running agy session, reconcile.sh's
|
||||
drift-C scan iterates every `*.db` in `~/.gemini/antigravity-cli/conversations/`
|
||||
and calls `verify_session_uuid(ws_a, 'agy', uuid, ..., mode='discover')`.
|
||||
Even when `last_conversations.json` maps `uuid_b` to another workspace `ws_b`,
|
||||
`verify_artifact` opens `uuid_b.db` to query `SELECT count(*) FROM steps`;
|
||||
with `_ro_connect`, no `-wal` or `-shm` sidecar is created beside `uuid_b.db`."""
|
||||
home = tmp_path / "home"
|
||||
ws_a = tmp_path / "ws_a"
|
||||
ws_b = tmp_path / "ws_b"
|
||||
ws_a.mkdir()
|
||||
ws_b.mkdir()
|
||||
|
||||
uuid_b = "b6400003-0000-4000-8000-000000000003"
|
||||
db_b = home / ".gemini" / "antigravity-cli" / "conversations" / f"{uuid_b}.db"
|
||||
lc = home / ".gemini" / "antigravity-cli" / "cache" / "last_conversations.json"
|
||||
lc.parent.mkdir(parents=True, exist_ok=True)
|
||||
lc.write_text(json.dumps({str(ws_b): uuid_b}))
|
||||
|
||||
opened = []
|
||||
listing = _make_wal_state(
|
||||
db_b, "clean",
|
||||
lambda c: (c.execute("CREATE TABLE steps (id INTEGER)"), c.execute("INSERT INTO steps VALUES (1)")),
|
||||
tmp_path, opened,
|
||||
)
|
||||
assert listing == [f"{uuid_b}.db"]
|
||||
|
||||
row_a = {
|
||||
"name": "ws-a-creator-agy",
|
||||
"status": "running",
|
||||
"agy_conversation_id_own": None,
|
||||
"herdr_session_epoch": 1000000001,
|
||||
"pane": {"cwd": str(ws_a), "cmd": "agy"},
|
||||
"_sibling_claimed_uuids": [],
|
||||
}
|
||||
assert verify_session_uuid(str(ws_a), "agy", uuid_b, row=row_a, home_dir=str(home), mode="discover") is True
|
||||
assert sorted(os.listdir(db_b.parent)) == [f"{uuid_b}.db"]
|
||||
Reference in new issue
Block a user