fix(opencode): a purge takes its child sessions' 1.x rows, the background service alone holds no session, XDG_DATA_HOME is followed (Issue #60, review)

- Review 614b6263 (codex, P2): `opencode session delete` removes the child
  sessions with their parent, but their 1.x rows stayed and the parent's
  residual was empty. The child sessions (session_v2 and a 1.x session
  table, any depth) are taken before the delete; the 1.x tables (message,
  part, todo, session_share, session) are cleaned for all of them, and the
  residual also reports a child still on record (a failed delete).
- The in-use check counted any process holding the shared opencode.db; the
  per-user 2.x background service (`serve --service`) holds it for every
  session, so every opencode purge was refused while it ran (it does on
  this Mac). It no longer counts; any other holder and a command line
  naming the session still do.
- The data dir follows the caller's XDG_DATA_HOME when ctx is the caller's
  own home (else <home>/.local/share), for debug paths, the fallback and the
  residual scan (review 614b6263 / f4cedbae N4).
- resident_children = ('opencode',): the --standalone server stays the TUI's
  idle child; what runs under it still counts (review 1abe0a90).
- Tests: child sessions on a migrated database, a failed delete, the service
  holder, XDG, resident children, drift C pinning the parent over a newer
  child session (review f4cedbae N2), and the real-binary test no longer
  depends on PATH (N1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-18 15:46:04 +09:00
1 parent 6b4b96fa18
commit cb5f439cce
4 files changed
+245 -41

No files matched your search

+109 -36
View File
@@ -1,8 +1,15 @@
import os, sys, sqlite3, shutil, subprocess
from urllib.parse import quote
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, 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
# writes (`opencode session delete` leaves them), and their session column.
_LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part", "session_id"),
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
("session", "id"), ("sessions", "id"))
_PROVIDER_ENV_VARS = (
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
@@ -59,6 +66,12 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
# blanks after the ┃) or the `Build auto · <model>` status line.
return r'^\s{20,}\S|^\s*(Build|Plan)\b.*·'
@property
def resident_children(self) -> tuple:
# --standalone keeps its server, `opencode serve --stdio --port 0`, as
# the TUI's child while idle (measured on 2.0.32, Issue #60).
return ('opencode',)
def _bin(self, ctx: DiscoveryContext) -> Optional[str]:
bin_path = shutil.which("opencode")
if bin_path:
@@ -71,6 +84,15 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
return candidate_bin
return None
def _data_home(self, ctx: DiscoveryContext) -> str:
"""The XDG data dir opencode uses for ctx's home: the caller's
XDG_DATA_HOME when ctx is the caller's own home, else <home>/.local/share."""
ambient = os.environ.get("XDG_DATA_HOME")
own = os.path.realpath(ctx.home_dir or "") == os.path.realpath(os.path.expanduser("~"))
if ambient and (own or not ctx.home_dir):
return ambient
return os.path.join(ctx.home_dir, ".local/share")
def _run(self, ctx: DiscoveryContext, args: list, timeout: int):
"""Run the opencode CLI against ctx's home (its data dir follows HOME and
XDG_DATA_HOME); None when there is no binary."""
@@ -81,7 +103,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
if ctx.home_dir:
env["HOME"] = ctx.home_dir
env["HOME_DIR"] = ctx.home_dir
env["XDG_DATA_HOME"] = os.path.join(ctx.home_dir, ".local/share")
env["XDG_DATA_HOME"] = self._data_home(ctx)
cwd = ctx.workspace or ctx.cwd or os.getcwd()
return subprocess.run([bin_path] + args, capture_output=True, text=True, timeout=timeout,
env=env, cwd=cwd if os.path.exists(cwd) else None)
@@ -98,7 +120,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
return parts[1].strip()
except Exception:
pass
return f"{ctx.home_dir}/.local/share/opencode/opencode.db"
return os.path.join(self._data_home(ctx), "opencode", "opencode.db")
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
db = self._resolve_db(ctx)
@@ -133,10 +155,15 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
purged = []
db = self._resolve_db(ctx)
if db and os.path.exists(db) and self._has_session(db, uuid):
if not db or not os.path.exists(db):
return purged
# The child sessions are taken before opencode deletes them with
# their parent: their 1.x rows are this purge's to remove too.
ids = [uuid] + self._descendants(db, uuid)
if self._has_session(db, uuid):
# opencode deletes a 2.x session with its child sessions and every
# row it keeps for them; --standalone so no background service is
# started for it.
# 2.x row it keeps for them; --standalone so no background service
# is started for it.
try:
res = self._run(ctx, ["session", "delete", "--standalone", "--", uuid], timeout=60)
if res is None:
@@ -148,33 +175,47 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
f"{(res.stderr or res.stdout).strip()}\n")
except Exception as e:
sys.stderr.write(f"WARN: purge opencode: session delete {uuid} failed: {e}\n")
if db and os.path.exists(db):
# Rows a 1.x opencode left in the tables a migration keeps.
try:
conn = sqlite3.connect(db)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in cursor.fetchall()}
if "message" in tables:
conn.execute("DELETE FROM message WHERE session_id=?", (uuid,))
if "messages" in tables:
conn.execute("DELETE FROM messages WHERE session_id=?", (uuid,))
if "part" in tables:
conn.execute("DELETE FROM part WHERE session_id=?", (uuid,))
if "parts" in tables:
conn.execute("DELETE FROM parts WHERE session_id=?", (uuid,))
if "session" in tables:
conn.execute("DELETE FROM session WHERE id=?", (uuid,))
if "sessions" in tables:
conn.execute("DELETE FROM sessions WHERE id=?", (uuid,))
conn.commit()
conn.close()
if tables & {"message", "messages", "part", "parts", "session", "sessions"}:
purged.append(f"sqlite rows for session: {uuid}")
except Exception as e:
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
# The rows a 1.x opencode left (a migration keeps those tables).
try:
conn = sqlite3.connect(db)
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
legacy = [(t, c) for t, c in _LEGACY_TABLES if t in tables]
for t, c in legacy:
for i in ids:
conn.execute(f'DELETE FROM "{t}" WHERE "{c}"=?', (i,))
conn.commit()
conn.close()
if legacy:
kids = f" and {len(ids) - 1} child session(s)" if len(ids) > 1 else ""
purged.append(f"sqlite rows for session: {uuid}{kids}")
except Exception as e:
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
return purged
def _descendants(self, db: str, uuid: str) -> list:
"""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)
except Exception:
return found
try:
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
sources = [t for t in ("session_v2", "session") if t in tables
and "parent_id" in {r[1] for r in conn.execute(f'PRAGMA table_info("{t}")')}]
while todo:
cur = todo.pop()
for t in sources:
for (cid,) in conn.execute(f'SELECT id FROM "{t}" WHERE parent_id=?', (cur,)):
if cid and cid != uuid and cid not in found:
found.append(cid)
todo.append(cid)
except Exception:
pass
finally:
conn.close()
return found
def _has_session(self, db: str, uuid: str) -> bool:
try:
conn = sqlite3.connect(db)
@@ -186,11 +227,43 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
return False
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #31: whatever still names the session (event-sourced rows a
# 1.x opencode left, a failed `session delete`) is reported.
found = scan_names(f"{ctx.home_dir}/.local/share/opencode", uuid)
return found + db_rows_naming(self._resolve_db(ctx), uuid, ("session_id", "aggregate_id"),
id_tables=(("session", "id"), ("session_v2", "id")))
# Issue #31: whatever still names the session or a child session still
# on record (a failed `session delete`) is reported.
db = self._resolve_db(ctx)
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")))
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
return found
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
"""A process naming the session, or an opencode process holding the
shared database. The per-user background service (`serve --service`)
holds it for every session and runs none of MAM's (MAM spawns
--standalone), so it alone does not make a session in use."""
if conversation_in_use(uuid, []):
return True
db = self._resolve_db(ctx)
if not db or not os.path.exists(db) or not shutil.which("lsof"):
return False
try:
pids = subprocess.run(["lsof", "-t", db], capture_output=True, text=True, timeout=10).stdout.split()
except Exception:
return True
for pid in pids:
try:
cmd = subprocess.run(["ps", "-o", "command=", "-p", pid], capture_output=True, text=True,
timeout=10).stdout
except Exception:
return True
if cmd.strip() and "serve --service" not in cmd:
return True
return False
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
return super().in_use_hints(uuid, ctx) + [
f"another opencode session holding {self._resolve_db(ctx)}: lsof {self._resolve_db(ctx)}"]
# opencode 2.x: the default agent is build (`--agent` is gone from the
# TUI), and --standalone keeps the server a child of the pane's TUI