diff --git a/.agents/skills/lib_py/agents/adapters/agy.py b/.agents/skills/lib_py/agents/adapters/agy.py index 948bc07..5ce7790 100644 --- a/.agents/skills/lib_py/agents/adapters/agy.py +++ b/.agents/skills/lib_py/agents/adapters/agy.py @@ -1,6 +1,6 @@ -import os, sys, json, sqlite3, shutil +import os, sys, json, shutil 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): @property @@ -71,7 +71,7 @@ class AgyAgentAdapter(BaseAgentAdapter): if uuid in (ctx.row.get("_sibling_claimed_uuids") or []): return False try: - conn = sqlite3.connect(path) + conn = _ro_connect(path) r = conn.execute("SELECT count(*) FROM steps").fetchone() conn.close() if not r or r[0] < 1: diff --git a/.agents/skills/lib_py/agents/adapters/codex.py b/.agents/skills/lib_py/agents/adapters/codex.py index 0c038da..4b4fa53 100644 --- a/.agents/skills/lib_py/agents/adapters/codex.py +++ b/.agents/skills/lib_py/agents/adapters/codex.py @@ -1,6 +1,6 @@ import os, json, glob, sqlite3, subprocess 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 class CodexAgentAdapter(BaseAgentAdapter): @@ -77,7 +77,7 @@ class CodexAgentAdapter(BaseAgentAdapter): if not db or not os.path.exists(db): return None, None try: - conn = sqlite3.connect(db) + conn = _ro_connect(db) row = conn.execute("SELECT rollout_path, cwd FROM threads WHERE id=?", (uuid,)).fetchone() conn.close() 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): try: - conn = sqlite3.connect(db) + conn = _ro_connect(db) cur = conn.cursor() cur.execute("PRAGMA table_info(threads)") cols = {r[1] for r in cur.fetchall()} diff --git a/.agents/skills/lib_py/agents/adapters/hermes.py b/.agents/skills/lib_py/agents/adapters/hermes.py index cb87397..277d20a 100644 --- a/.agents/skills/lib_py/agents/adapters/hermes.py +++ b/.agents/skills/lib_py/agents/adapters/hermes.py @@ -1,6 +1,6 @@ import os, sys, sqlite3 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 class HermesAgentAdapter(BaseAgentAdapter): @@ -48,7 +48,7 @@ class HermesAgentAdapter(BaseAgentAdapter): if not os.path.exists(hdb): return False try: - conn = sqlite3.connect(hdb) + conn = _ro_connect(hdb) r = conn.execute("SELECT cwd, started_at FROM sessions WHERE id=?", (uuid,)).fetchone() conn.close() if not r: @@ -97,7 +97,7 @@ class HermesAgentAdapter(BaseAgentAdapter): if not os.path.exists(hdb): return [] try: - conn = sqlite3.connect(hdb) + conn = _ro_connect(hdb) if ctx.epoch: rows = conn.execute( "SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20", diff --git a/.agents/skills/lib_py/agents/adapters/opencode.py b/.agents/skills/lib_py/agents/adapters/opencode.py index b85c7be..3ea89fb 100644 --- a/.agents/skills/lib_py/agents/adapters/opencode.py +++ b/.agents/skills/lib_py/agents/adapters/opencode.py @@ -1,7 +1,6 @@ -import os, sys, sqlite3, shutil, subprocess, tempfile -from urllib.parse import quote +import os, sys, sqlite3, shutil, subprocess 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 # 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"), ("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. _DB_PATHS = {} diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index 3a9d572..51cc6d9 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -1,6 +1,6 @@ # 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 urllib.parse import quote from lib_py.paths import resolve_home @@ -108,15 +108,41 @@ def scan_names(root: str, uuid: str, skip=()) -> list: 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: """" (.: rows)" for every table holding rows of this 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. - `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): return [] 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: return [] found = [] diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 8a967a0..7711f66 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -420,7 +420,7 @@ import yaml from lib_py.verify_session import verify_session_uuid, workspace_key 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): if not path: @@ -862,7 +862,7 @@ for s in d.get('herdr_sessions', []): valid_candidates = [] try: - conn = sqlite3.connect(hdb) + conn = _ro_connect(hdb) rows = conn.execute( "SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20", (cwd, epoch_threshold) @@ -951,7 +951,6 @@ for s in d.get('herdr_sessions', []): valid_candidates = [] try: # 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) cursor = conn.cursor() # 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 = [] if state_db and os.path.exists(state_db): try: - conn = sqlite3.connect(state_db) + conn = _ro_connect(state_db) 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) if order_col: diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index 9614c35..fa9722b 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -87,8 +87,8 @@ def resume_on_disk(s): if not os.path.exists(hdb): return 'MISSING' try: - import sqlite3 - conn = sqlite3.connect(hdb) + from lib_py.agents.base import _ro_connect + conn = _ro_connect(hdb) r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone() conn.close() return 'yes' if r else 'MISSING' @@ -107,13 +107,12 @@ def resume_on_disk(s): if u: # 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.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)) if not odb or not os.path.exists(odb): return 'MISSING' try: # 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) r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x conn.close() @@ -129,8 +128,8 @@ def resume_on_disk(s): if db_matches: db = max(db_matches, key=os.path.getmtime) try: - import sqlite3 - conn = sqlite3.connect(db) + from lib_py.agents.base import _ro_connect + conn = _ro_connect(db) r = conn.execute("SELECT rollout_path FROM threads WHERE id=?", (u,)).fetchone() conn.close() 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): return 'MISSING' try: - import sqlite3 - conn = sqlite3.connect(hdb) + from lib_py.agents.base import _ro_connect + conn = _ro_connect(hdb) r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone() conn.close() return 'yes' if r else 'MISSING' @@ -308,13 +307,12 @@ def resume_on_disk(s): if u: # 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.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)) if not odb or not os.path.exists(odb): return 'MISSING' try: # 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) r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x conn.close() @@ -330,8 +328,8 @@ def resume_on_disk(s): if db_matches: db = max(db_matches, key=os.path.getmtime) try: - import sqlite3 - conn = sqlite3.connect(db) + from lib_py.agents.base import _ro_connect + conn = _ro_connect(db) r = conn.execute("SELECT rollout_path FROM threads WHERE id=?", (u,)).fetchone() conn.close() if r and r[0] and os.path.exists(r[0]): diff --git a/tests/test_issue64_ro_connect.py b/tests/test_issue64_ro_connect.py new file mode 100644 index 0000000..63c9e9c --- /dev/null +++ b/tests/test_issue64_ro_connect.py @@ -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/.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"]