- BaseAgentAdapter: id_lifecycle (pre_assigned: claude, grok / post_discovered) and capture_active_session_id - exactly one verified candidate newer than the session that no other row records, else a reason (no-candidate, ambiguous, ...). - agy: verify_artifact (discover) rejects an id last_conversations.json maps to another workspace and always excludes ids other rows record (a cache hit no longer bypasses it). capture uses the cache only as a proposal: the conversation's presence lock or DB must be held by the row's pane process or a descendant (lsof/ps); anything unprovable is not pinned. - lib_py/agents/autopin.py: handle_completed, run by the side that verified the job's completed - the delegate-job wrapper (direct), create --onboard and the loop's wait_for_job (recovery, as a subprocess: G9). Local herdr jobs only; one (job, dispatch token, attempt) is handled once (job-scoped flock + marker in the job dir); writes go through atomic_dump_yaml; a pin records session_id_source=discovered / session_id_verified=true; a failure leaves the reason on the row and never changes the job's result. - reconcile.sh drift C for agy, hermes, opencode and codex calls the same capture (the agy *.db glob is gone); _pin_and_verify_resume uses pin_row. - Tests: tests/test_issue40_auto_pin.py; issue64 :407 now expects False; the static reconcile-source checks follow the move to the adapters; the two tier2 drift-C component tests create with --no-onboard (onboarding now pins). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
17 KiB
Python
410 lines
17 KiB
Python
"""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
|
|
|
|
# Issue #40: drift C reads the agents' databases only through the adapters
|
|
# (lib_py.agents.autopin.capture), which use _ro_connect.
|
|
reconcile_text = (SKILLS / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh").read_text()
|
|
assert "autopin.capture(s, agent, d, home_dir=home)" in reconcile_text
|
|
assert "_ro_connect" not in reconcile_text
|
|
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):
|
|
"""`verify_session_uuid(ws_a, 'agy', uuid_b, ..., mode='discover')` for an
|
|
unpinned running agy session in workspace A leaves no `-wal` or `-shm`
|
|
sidecar beside another workspace's `uuid_b.db` (reconcile.sh's drift C
|
|
used to call it for every `*.db` in the conversations folder). Since Issue
|
|
#40 an id `last_conversations.json` maps to another workspace is rejected."""
|
|
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": [],
|
|
}
|
|
# Issue #40: an id the cache maps to another workspace is rejected
|
|
assert verify_session_uuid(str(ws_a), "agy", uuid_b, row=row_a, home_dir=str(home), mode="discover") is False
|
|
assert sorted(os.listdir(db_b.parent)) == [f"{uuid_b}.db"]
|