fix(issue-31): a claude purge removes every path the session owns, reports what is left, and waits for the process; the stop docs say a purge removes the row #59

Merged
Godopu merged 26 commits from fix/issue-31 into main 2026-10-17 16:25:21 +00:00
4 changed files with 61 additions and 3 deletions
Showing only changes of commit b3461377ae - Show all commits

No files matched your search

+12 -1
View File
@@ -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 from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, 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):
@@ -158,6 +158,17 @@ class CodexAgentAdapter(BaseAgentAdapter):
pass pass
return purged return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
# Issue #31: names under CODEX_HOME plus thread rows. logs_*.sqlite is
# not scanned (no thread index: a full scan of a large file).
root = self._codex_home(ctx)
found = scan_names(root, uuid)
for db in sorted(glob.glob(f"{root}/state_*.sqlite")):
found += db_rows_naming(db, uuid, ("thread_id",), id_tables=(("threads", "id"),))
for db in sorted(glob.glob(f"{root}/thread_history_*.sqlite")):
found += db_rows_naming(db, uuid, ("thread_id",))
return found
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --dangerously-bypass-approvals-and-sandbox" return f"{binary} --dangerously-bypass-approvals-and-sandbox"
@@ -1,7 +1,7 @@
import os, json, glob, shutil import os, json, glob, shutil
from typing import Optional, Any from typing import Optional, Any
from urllib.parse import quote from urllib.parse import quote
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
@@ -108,6 +108,11 @@ class GrokAgentAdapter(BaseAgentAdapter):
purged.append(self.artifact_path(uuid, ctx)) purged.append(self.artifact_path(uuid, ctx))
return purged return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #31: reported, not removed - memory-v2 observations are the
# shared store of grok's memory pipeline.
return scan_names(f"{ctx.home_dir}/.grok", uuid)
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
if session_uuid: if session_uuid:
return f"{binary} --session-id {session_uuid} --permission-mode bypassPermissions" return f"{binary} --session-id {session_uuid} --permission-mode bypassPermissions"
@@ -1,6 +1,6 @@
import os, sys, sqlite3, shutil import os, sys, sqlite3, shutil
from typing import Optional, Any, List from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
_PROVIDER_ENV_VARS = ( _PROVIDER_ENV_VARS = (
@@ -194,6 +194,13 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n") sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
return purged return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #31: the purge leaves event-sourced rows (event, todo, ...);
# deleting those is not known to be safe, so they are 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"),))
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --auto --agent build" return f"{binary} --auto --agent build"
+35
View File
@@ -2,6 +2,7 @@
import os, json, sqlite3, shutil, subprocess import os, json, sqlite3, shutil, subprocess
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from urllib.parse import quote
from lib_py.paths import resolve_home from lib_py.paths import resolve_home
from lib_py.verify_session import verify_session_uuid, workspace_key from lib_py.verify_session import verify_session_uuid, workspace_key
@@ -107,6 +108,40 @@ def scan_names(root: str, uuid: str, skip=()) -> list:
return sorted(found) return sorted(found)
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=()) -> list:
""""<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`
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped."""
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)
except Exception:
return []
found = []
try:
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for table in tables:
qt = table.replace('"', '""')
try:
cols = {r[1] for r in conn.execute(f'PRAGMA table_info("{qt}")')}
except Exception:
continue
wanted = [c for c in columns if c in cols]
wanted += [c for t, c in id_tables if t == table and c in cols and c not in wanted]
for col in wanted:
try:
n = conn.execute(f'SELECT count(*) FROM "{qt}" WHERE "{col}"=?', (uuid,)).fetchone()[0]
except Exception:
continue
if n:
found.append(f"{db} ({table}.{col}: {n} rows)")
except Exception:
pass
finally:
conn.close()
return found
class SpawnSpec: class SpawnSpec:
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None): def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
self.agent_name = agent_name self.agent_name = agent_name