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
@@ -1,6 +1,6 @@
|
|||||||
import os, sys, json, sqlite3, shutil
|
import os, sys, json, shutil
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, conversation_in_use
|
||||||
|
|
||||||
class AgyAgentAdapter(BaseAgentAdapter):
|
class AgyAgentAdapter(BaseAgentAdapter):
|
||||||
@property
|
@property
|
||||||
@@ -71,7 +71,7 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
|||||||
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
|
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(path)
|
conn = _ro_connect(path)
|
||||||
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not r or r[0] < 1:
|
if not r or r[0] < 1:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os, json, glob, sqlite3, subprocess
|
import os, json, glob, sqlite3, subprocess
|
||||||
from typing import Optional, Any, List
|
from typing import Optional, Any, List
|
||||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, scan_names, db_rows_naming
|
||||||
from lib_py.verify_session import workspace_key
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
class CodexAgentAdapter(BaseAgentAdapter):
|
class CodexAgentAdapter(BaseAgentAdapter):
|
||||||
@@ -77,7 +77,7 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
|||||||
if not db or not os.path.exists(db):
|
if not db or not os.path.exists(db):
|
||||||
return None, None
|
return None, None
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
row = conn.execute("SELECT rollout_path, cwd FROM threads WHERE id=?", (uuid,)).fetchone()
|
row = conn.execute("SELECT rollout_path, cwd FROM threads WHERE id=?", (uuid,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if row and row[0] and os.path.exists(row[0]):
|
if row and row[0] and os.path.exists(row[0]):
|
||||||
@@ -192,7 +192,7 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
|||||||
|
|
||||||
if db and os.path.exists(db):
|
if db and os.path.exists(db):
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("PRAGMA table_info(threads)")
|
cur.execute("PRAGMA table_info(threads)")
|
||||||
cols = {r[1] for r in cur.fetchall()}
|
cols = {r[1] for r in cur.fetchall()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os, sys, sqlite3
|
import os, sys, sqlite3
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect
|
||||||
from lib_py.verify_session import workspace_key
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
class HermesAgentAdapter(BaseAgentAdapter):
|
class HermesAgentAdapter(BaseAgentAdapter):
|
||||||
@@ -48,7 +48,7 @@ class HermesAgentAdapter(BaseAgentAdapter):
|
|||||||
if not os.path.exists(hdb):
|
if not os.path.exists(hdb):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(hdb)
|
conn = _ro_connect(hdb)
|
||||||
r = conn.execute("SELECT cwd, started_at FROM sessions WHERE id=?", (uuid,)).fetchone()
|
r = conn.execute("SELECT cwd, started_at FROM sessions WHERE id=?", (uuid,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not r:
|
if not r:
|
||||||
@@ -97,7 +97,7 @@ class HermesAgentAdapter(BaseAgentAdapter):
|
|||||||
if not os.path.exists(hdb):
|
if not os.path.exists(hdb):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(hdb)
|
conn = _ro_connect(hdb)
|
||||||
if ctx.epoch:
|
if ctx.epoch:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20",
|
"SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import os, sys, sqlite3, shutil, subprocess, tempfile
|
import os, sys, sqlite3, shutil, subprocess
|
||||||
from urllib.parse import quote
|
|
||||||
from typing import Optional, Any, List
|
from typing import Optional, Any, List
|
||||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming, conversation_in_use
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, scan_names, db_rows_naming, conversation_in_use
|
||||||
from lib_py.verify_session import workspace_key
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
# Tables a 1.x opencode wrote and a migration to 2.x keeps but no longer
|
# Tables a 1.x opencode wrote and a migration to 2.x keeps but no longer
|
||||||
@@ -10,31 +9,6 @@ _LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part"
|
|||||||
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
|
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
|
||||||
("session", "id"), ("sessions", "id"))
|
("session", "id"), ("sessions", "id"))
|
||||||
|
|
||||||
def _ro_connect(db: str):
|
|
||||||
"""A read that neither creates nor removes anything beside db (Issue #60).
|
|
||||||
Both WAL sidecars (a live writer keeps them): read through them. No -wal:
|
|
||||||
nothing to apply, so the file is read as immutable. A -wal without its
|
|
||||||
-shm (left behind - no writer runs without one) still holds committed
|
|
||||||
rows: they are read from a private copy, where SQLite may rebuild the
|
|
||||||
index, loaded into memory."""
|
|
||||||
path = os.path.abspath(db)
|
|
||||||
wal, shm = os.path.exists(path + "-wal"), os.path.exists(path + "-shm")
|
|
||||||
if wal and shm:
|
|
||||||
return sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True)
|
|
||||||
if not wal:
|
|
||||||
return sqlite3.connect(f"file:{quote(path)}?mode=ro&immutable=1", uri=True)
|
|
||||||
mem = sqlite3.connect(":memory:")
|
|
||||||
with tempfile.TemporaryDirectory(prefix="mam-opencode-") as tmp:
|
|
||||||
copy = os.path.join(tmp, "opencode.db")
|
|
||||||
shutil.copyfile(path, copy)
|
|
||||||
shutil.copyfile(path + "-wal", copy + "-wal")
|
|
||||||
src = sqlite3.connect(copy)
|
|
||||||
try:
|
|
||||||
src.backup(mem)
|
|
||||||
finally:
|
|
||||||
src.close()
|
|
||||||
return mem
|
|
||||||
|
|
||||||
|
|
||||||
# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them.
|
# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them.
|
||||||
_DB_PATHS = {}
|
_DB_PATHS = {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
|
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
|
||||||
|
|
||||||
import os, json, sqlite3, shutil, subprocess
|
import os, json, sqlite3, shutil, subprocess, tempfile
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from lib_py.paths import resolve_home
|
from lib_py.paths import resolve_home
|
||||||
@@ -108,15 +108,41 @@ def scan_names(root: str, uuid: str, skip=()) -> list:
|
|||||||
return sorted(found)
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
def _ro_connect(db: str):
|
||||||
|
"""A read that neither creates nor removes anything beside db (Issues #60, #64).
|
||||||
|
Both WAL sidecars (a live writer keeps them): read through them. No -wal:
|
||||||
|
nothing to apply, so the file is read as immutable. A -wal without its
|
||||||
|
-shm (left behind - no writer runs without one) still holds committed
|
||||||
|
rows: they are read from a private copy, where SQLite may rebuild the
|
||||||
|
index, loaded into memory."""
|
||||||
|
path = os.path.abspath(db)
|
||||||
|
wal, shm = os.path.exists(path + "-wal"), os.path.exists(path + "-shm")
|
||||||
|
if wal and shm:
|
||||||
|
return sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True)
|
||||||
|
if not wal:
|
||||||
|
return sqlite3.connect(f"file:{quote(path)}?mode=ro&immutable=1", uri=True)
|
||||||
|
mem = sqlite3.connect(":memory:")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mam-ro-") as tmp:
|
||||||
|
copy = os.path.join(tmp, os.path.basename(path) or "state.db")
|
||||||
|
shutil.copyfile(path, copy)
|
||||||
|
shutil.copyfile(path + "-wal", copy + "-wal")
|
||||||
|
src = sqlite3.connect(copy)
|
||||||
|
try:
|
||||||
|
src.backup(mem)
|
||||||
|
finally:
|
||||||
|
src.close()
|
||||||
|
return mem
|
||||||
|
|
||||||
|
|
||||||
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=(), connect=None) -> list:
|
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=(), connect=None) -> list:
|
||||||
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this
|
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this
|
||||||
conversation in one of `columns`, or in the id column of an `id_tables`
|
conversation in one of `columns`, or in the id column of an `id_tables`
|
||||||
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped.
|
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped.
|
||||||
`connect(db)` opens the database (default: a mode=ro URI)."""
|
`connect(db)` opens the database (default: `_ro_connect`)."""
|
||||||
if not _plausible_id(uuid) or not db or not os.path.isfile(db):
|
if not _plausible_id(uuid) or not db or not os.path.isfile(db):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
conn = connect(db) if connect else sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
|
conn = (connect or _ro_connect)(db)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
found = []
|
found = []
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ import yaml
|
|||||||
|
|
||||||
from lib_py.verify_session import verify_session_uuid, workspace_key
|
from lib_py.verify_session import verify_session_uuid, workspace_key
|
||||||
from lib_py.agents.registry import get_adapter
|
from lib_py.agents.registry import get_adapter
|
||||||
from lib_py.agents.base import DiscoveryContext
|
from lib_py.agents.base import DiscoveryContext, _ro_connect
|
||||||
|
|
||||||
def _slug(path):
|
def _slug(path):
|
||||||
if not path:
|
if not path:
|
||||||
@@ -862,7 +862,7 @@ for s in d.get('herdr_sessions', []):
|
|||||||
|
|
||||||
valid_candidates = []
|
valid_candidates = []
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(hdb)
|
conn = _ro_connect(hdb)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20",
|
"SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20",
|
||||||
(cwd, epoch_threshold)
|
(cwd, epoch_threshold)
|
||||||
@@ -951,7 +951,6 @@ for s in d.get('herdr_sessions', []):
|
|||||||
valid_candidates = []
|
valid_candidates = []
|
||||||
try:
|
try:
|
||||||
# read-only: nothing is created or removed beside the agent's db (Issue #60)
|
# read-only: nothing is created or removed beside the agent's db (Issue #60)
|
||||||
from lib_py.agents.adapters.opencode import _ro_connect
|
|
||||||
conn = _ro_connect(odb)
|
conn = _ro_connect(odb)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
# opencode 2.x keeps sessions in session_v2 (a migrated 1.x `session`
|
# opencode 2.x keeps sessions in session_v2 (a migrated 1.x `session`
|
||||||
@@ -1046,7 +1045,7 @@ for s in d.get('herdr_sessions', []):
|
|||||||
rows = []
|
rows = []
|
||||||
if state_db and os.path.exists(state_db):
|
if state_db and os.path.exists(state_db):
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(state_db)
|
conn = _ro_connect(state_db)
|
||||||
cols = [c[1] for c in conn.execute("PRAGMA table_info(threads)").fetchall()]
|
cols = [c[1] for c in conn.execute("PRAGMA table_info(threads)").fetchall()]
|
||||||
order_col = "updated_at" if "updated_at" in cols else ("created_at" if "created_at" in cols else None)
|
order_col = "updated_at" if "updated_at" in cols else ("created_at" if "created_at" in cols else None)
|
||||||
if order_col:
|
if order_col:
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ def resume_on_disk(s):
|
|||||||
if not os.path.exists(hdb):
|
if not os.path.exists(hdb):
|
||||||
return 'MISSING'
|
return 'MISSING'
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
from lib_py.agents.base import _ro_connect
|
||||||
conn = sqlite3.connect(hdb)
|
conn = _ro_connect(hdb)
|
||||||
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone()
|
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
return 'yes' if r else 'MISSING'
|
return 'yes' if r else 'MISSING'
|
||||||
@@ -107,13 +107,12 @@ def resume_on_disk(s):
|
|||||||
if u:
|
if u:
|
||||||
# the adapter's db: `opencode debug paths`, which follows XDG_DATA_HOME (Issue #60)
|
# the adapter's db: `opencode debug paths`, which follows XDG_DATA_HOME (Issue #60)
|
||||||
from lib_py.agents.registry import get_adapter
|
from lib_py.agents.registry import get_adapter
|
||||||
from lib_py.agents.base import DiscoveryContext
|
from lib_py.agents.base import DiscoveryContext, _ro_connect
|
||||||
odb = get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
|
odb = get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
|
||||||
if not odb or not os.path.exists(odb):
|
if not odb or not os.path.exists(odb):
|
||||||
return 'MISSING'
|
return 'MISSING'
|
||||||
try:
|
try:
|
||||||
# read-only: nothing is created or removed beside the agent's db (Issue #60)
|
# read-only: nothing is created or removed beside the agent's db (Issue #60)
|
||||||
from lib_py.agents.adapters.opencode import _ro_connect
|
|
||||||
conn = _ro_connect(odb)
|
conn = _ro_connect(odb)
|
||||||
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
|
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -129,8 +128,8 @@ def resume_on_disk(s):
|
|||||||
if db_matches:
|
if db_matches:
|
||||||
db = max(db_matches, key=os.path.getmtime)
|
db = max(db_matches, key=os.path.getmtime)
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
from lib_py.agents.base import _ro_connect
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
r = conn.execute("SELECT rollout_path FROM threads WHERE id=?", (u,)).fetchone()
|
r = conn.execute("SELECT rollout_path FROM threads WHERE id=?", (u,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if r and r[0] and os.path.exists(r[0]):
|
if r and r[0] and os.path.exists(r[0]):
|
||||||
@@ -288,8 +287,8 @@ def resume_on_disk(s):
|
|||||||
if not os.path.exists(hdb):
|
if not os.path.exists(hdb):
|
||||||
return 'MISSING'
|
return 'MISSING'
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
from lib_py.agents.base import _ro_connect
|
||||||
conn = sqlite3.connect(hdb)
|
conn = _ro_connect(hdb)
|
||||||
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone()
|
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
return 'yes' if r else 'MISSING'
|
return 'yes' if r else 'MISSING'
|
||||||
@@ -308,13 +307,12 @@ def resume_on_disk(s):
|
|||||||
if u:
|
if u:
|
||||||
# the adapter's db: `opencode debug paths`, which follows XDG_DATA_HOME (Issue #60)
|
# the adapter's db: `opencode debug paths`, which follows XDG_DATA_HOME (Issue #60)
|
||||||
from lib_py.agents.registry import get_adapter
|
from lib_py.agents.registry import get_adapter
|
||||||
from lib_py.agents.base import DiscoveryContext
|
from lib_py.agents.base import DiscoveryContext, _ro_connect
|
||||||
odb = get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
|
odb = get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
|
||||||
if not odb or not os.path.exists(odb):
|
if not odb or not os.path.exists(odb):
|
||||||
return 'MISSING'
|
return 'MISSING'
|
||||||
try:
|
try:
|
||||||
# read-only: nothing is created or removed beside the agent's db (Issue #60)
|
# read-only: nothing is created or removed beside the agent's db (Issue #60)
|
||||||
from lib_py.agents.adapters.opencode import _ro_connect
|
|
||||||
conn = _ro_connect(odb)
|
conn = _ro_connect(odb)
|
||||||
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
|
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -330,8 +328,8 @@ def resume_on_disk(s):
|
|||||||
if db_matches:
|
if db_matches:
|
||||||
db = max(db_matches, key=os.path.getmtime)
|
db = max(db_matches, key=os.path.getmtime)
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
from lib_py.agents.base import _ro_connect
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
r = conn.execute("SELECT rollout_path FROM threads WHERE id=?", (u,)).fetchone()
|
r = conn.execute("SELECT rollout_path FROM threads WHERE id=?", (u,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if r and r[0] and os.path.exists(r[0]):
|
if r and r[0] and os.path.exists(r[0]):
|
||||||
|
|||||||
@@ -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