From ac5f1154c70ead0d8d29d9ec628f0a6e0aa2c193 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 18 Oct 2026 12:31:44 +0000 Subject: [PATCH] fix(opencode): queries open the database read-only without touching its sidecars (Issue #60, Linux item 5) A read-write open checkpointed the WAL and deleted -wal/-shm on close; a plain mode=ro open created missing ones and left them. _ro_connect uses the sidecars only when both exist (a live writer keeps them) and reads the file as immutable otherwise. verify_artifact, _has_session, discover, _descendants and residual_artifacts (db_rows_naming takes a connect) use it; the purge's own writes are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../skills/lib_py/agents/adapters/opencode.py | 19 +++-- .agents/skills/lib_py/agents/base.py | 7 +- tests/test_issue60_linux_fixes.py | 85 +++++++++++++++++++ 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/.agents/skills/lib_py/agents/adapters/opencode.py b/.agents/skills/lib_py/agents/adapters/opencode.py index 2935c6b..eacf656 100644 --- a/.agents/skills/lib_py/agents/adapters/opencode.py +++ b/.agents/skills/lib_py/agents/adapters/opencode.py @@ -10,6 +10,15 @@ _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): + the WAL sidecars are used only when both already exist (a live writer + keeps them); otherwise the file is read as immutable.""" + path = os.path.abspath(db) + both = os.path.exists(path + "-wal") and os.path.exists(path + "-shm") + return sqlite3.connect(f"file:{quote(path)}?mode=ro" + ("" if both else "&immutable=1"), uri=True) + + _PROVIDER_ENV_VARS = ( 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', @@ -139,7 +148,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): try: # opencode 2.x keeps sessions in session_v2 (a 1.x `session` table # survives a migration but is no longer written). - conn = sqlite3.connect(db) + conn = _ro_connect(db) r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone() conn.close() if not r: @@ -202,7 +211,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): """Child sessions (any depth) of uuid in session_v2 and a 1.x session table.""" found, todo = [], [uuid] try: - conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True) + conn = _ro_connect(db) except Exception: return found try: @@ -224,7 +233,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): def _has_session(self, db: str, uuid: str) -> bool: try: - conn = sqlite3.connect(db) + conn = _ro_connect(db) try: return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None finally: @@ -239,7 +248,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): found = scan_names(os.path.join(self._data_home(ctx), "opencode"), uuid) for i in [uuid] + (self._descendants(db, uuid) if db and os.path.isfile(db) else []): rows = db_rows_naming(db, i, ("session_id", "aggregate_id"), - id_tables=(("session", "id"), ("session_v2", "id"))) + id_tables=(("session", "id"), ("session_v2", "id")), connect=_ro_connect) found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows] return found @@ -306,7 +315,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): params.append(int(float(ctx.epoch) * 1000)) query += " ORDER BY time_created DESC LIMIT 20" - conn = sqlite3.connect(db) + conn = _ro_connect(db) rows = conn.execute(query, params).fetchall() conn.close() candidates = [] diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index dbb234f..3a9d572 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -108,14 +108,15 @@ def scan_names(root: str, uuid: str, skip=()) -> list: return sorted(found) -def db_rows_naming(db: str, uuid: str, columns=(), id_tables=()) -> list: +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.""" + (table, column) pair (Issue #31). Read-only; unreadable tables are skipped. + `connect(db)` opens the database (default: a mode=ro URI).""" if not _plausible_id(uuid) or not db or not os.path.isfile(db): return [] try: - conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True) + conn = connect(db) if connect else sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True) except Exception: return [] found = [] diff --git a/tests/test_issue60_linux_fixes.py b/tests/test_issue60_linux_fixes.py index bb0a124..9513f96 100644 --- a/tests/test_issue60_linux_fixes.py +++ b/tests/test_issue60_linux_fixes.py @@ -2,11 +2,17 @@ 1. The 2.x home screen's placeholder (`Ask anything… ""`, cut without its closing quote in a narrow box) is not typed input. +5. The adapter's queries neither create nor remove anything beside + opencode's database (a read-write open checkpoints the WAL and deletes + the sidecars on close; a plain mode=ro open creates missing ones). """ from __future__ import annotations +import os import re +import shutil +import sqlite3 import sys from pathlib import Path @@ -19,6 +25,7 @@ if str(SKILLS) not in sys.path: sys.path.insert(0, str(SKILLS)) from lib_py import stall_watch # noqa: E402 +from lib_py.agents.base import DiscoveryContext # noqa: E402 from lib_py.agents.input_region import extract_input_region # noqa: E402 from lib_py.agents.registry import get_adapter # noqa: E402 @@ -80,3 +87,81 @@ def test_stall_watch_sees_the_2x_home_box_empty(name, monkeypatch): def test_other_adapters_have_no_placeholder_pattern(): for agent in ("claude", "agy", "grok", "codex", "hermes"): assert get_adapter(agent).input_placeholder_pattern is None, agent + + +# --------------------------------------------------------------------------- +# 5. the queries leave the files beside the database alone +# --------------------------------------------------------------------------- +V2_DDL = ("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)", + "CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)") + +QUERIES = { + "verify_artifact": (lambda a, ctx, db: a.verify_artifact("ses_main", ctx), True), + "discover": (lambda a, ctx, db: a.discover(ctx), ["ses_main"]), + "_has_session": (lambda a, ctx, db: a._has_session(db, "ses_main"), True), + "_descendants": (lambda a, ctx, db: a._descendants(db, "ses_main"), ["ses_child"]), + "residual_artifacts": (lambda a, ctx, db: any("session_v2.id: 1 rows" in r + for r in a.residual_artifacts("ses_main", ctx)), True), +} + + +def _wal_writer(db: Path, ws: Path) -> sqlite3.Connection: + """An open WAL connection holding the schema and two sessions: until it + closes, the rows live in the -wal only.""" + conn = sqlite3.connect(str(db)) + conn.execute("PRAGMA journal_mode=wal") + for stmt in V2_DDL: + conn.execute(stmt) + conn.execute("INSERT INTO session_v2 VALUES ('ses_main', NULL, ?, 2000000)", (str(ws),)) + conn.execute("INSERT INTO session_v2 VALUES ('ses_child', 'ses_main', ?, 3000000)", (str(ws),)) + conn.execute("INSERT INTO session_message VALUES ('msg-1', 'ses_main')") + conn.commit() + return conn + + +@pytest.fixture +def wal_home(tmp_path, monkeypatch): + """(adapter, ctx, db, ws, make(state)) - make builds the database in one + of three states: `clean` (closed: no sidecars), `leftover` (both sidecars + left behind by a writer that is gone, the rows only in the -wal), + `writer` (a live writer whose rows are only in the -wal).""" + home, ws = tmp_path / "home", tmp_path / "ws" + ws.mkdir() + db = home / ".local" / "share" / "opencode" / "opencode.db" + db.parent.mkdir(parents=True) + adapter = get_adapter("opencode") + monkeypatch.setattr(adapter, "_bin", lambda ctx: None) # the default path, no subprocess + ctx = DiscoveryContext(workspace=str(ws), agent_name="opencode", home_dir=str(home)) + opened = [] + + def make(state): + if state == "clean": + _wal_writer(db, ws).close() + elif state == "leftover": + src = tmp_path / "src" / "opencode.db" + src.parent.mkdir() + conn = _wal_writer(src, ws) + for suffix in ("", "-wal", "-shm"): + shutil.copyfile(str(src) + suffix, str(db) + suffix) + conn.close() + else: + opened.append(_wal_writer(db, ws)) + return sorted(os.listdir(db.parent)) + + yield adapter, ctx, str(db), ws, make + for conn in opened: + conn.close() + + +@pytest.mark.parametrize("query", sorted(QUERIES)) +@pytest.mark.parametrize("state,listing", [ + ("clean", ["opencode.db"]), + ("leftover", ["opencode.db", "opencode.db-shm", "opencode.db-wal"]), + ("writer", ["opencode.db", "opencode.db-shm", "opencode.db-wal"]), +]) +def test_a_query_reads_right_and_leaves_the_sidecars_alone(wal_home, state, listing, query): + adapter, ctx, db, ws, make = wal_home + assert make(state) == listing + run, expected = QUERIES[query] + assert run(adapter, ctx, db) == expected + assert sorted(os.listdir(os.path.dirname(db))) == listing