fix(agents): in_use_hints lists the processes holding the conversation file (lsof) (#78)

This commit is contained in:
Godopu committed 2026-10-29 12:52:17 +00:00
1 parent a3364e82e1
commit c6eb4564d2
4 files changed
+312 -6

No files matched your search

+4 -1
View File
@@ -197,11 +197,14 @@ class AgyAgentAdapter(BaseAgentAdapter):
# the DB already gone, and those orphans must still be reachable.
return any(os.path.exists(p) for p in self._artifact_paths(uuid, ctx))
def in_use_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
return self._artifact_paths(uuid, ctx)
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
# The holder may keep the presence lock (or a sidecar) open rather
# than the DB, and may name the uuid nowhere; the lsof leg of the
# check must see every path of the conversation.
return conversation_in_use(uuid, self._artifact_paths(uuid, ctx))
return conversation_in_use(uuid, self.in_use_paths(uuid, ctx))
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #10: a purge must leave nothing resumable behind. Besides the
@@ -13,6 +13,11 @@ _LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part"
# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them.
_DB_PATHS = {}
def _is_shared_service(cmd: str) -> bool:
"""The per-user opencode background service (`serve --service`)."""
return "serve --service" in (cmd or "")
class OpenCodeAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
@@ -250,6 +255,13 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
return found
def in_use_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
db = self._resolve_db(ctx)
return [db] if db else []
def _holder_counts(self, cmd: str) -> bool:
return not _is_shared_service(cmd)
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`)
@@ -270,13 +282,14 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
timeout=10).stdout
except Exception:
return True
if cmd.strip() and "serve --service" not in cmd:
if cmd.strip() and not _is_shared_service(cmd):
return True
return False
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
db = self._resolve_db(ctx)
return super().in_use_hints(uuid, ctx) + [
f"another opencode session holding {self._resolve_db(ctx)}: lsof {self._resolve_db(ctx)}"]
f"another opencode session holding {db}: lsof {db}"]
# 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
+42 -3
View File
@@ -1,7 +1,7 @@
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
import os, json, sqlite3, shutil, subprocess, tempfile
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, List, Tuple
from urllib.parse import quote
from lib_py.paths import resolve_home
from lib_py.verify_session import verify_session_uuid, workspace_key
@@ -86,6 +86,34 @@ def conversation_in_use(uuid: str, paths=()) -> bool:
return False
def file_holders(paths=()) -> List[Tuple[int, str]]:
"""(pid, command_line) for each process holding one of `paths` open via lsof (#78).
Informational only: returns [] when lsof is absent or fails."""
existing = [p for p in paths if p and os.path.exists(p)]
if not existing or not shutil.which("lsof"):
return []
try:
r = subprocess.run(["lsof", "-t"] + existing, capture_output=True, text=True, timeout=10)
except Exception:
return []
pids: List[int] = []
for tok in (r.stdout or "").split():
if tok.isdigit():
pid = int(tok)
if pid not in pids:
pids.append(pid)
out: List[Tuple[int, str]] = []
for pid in pids:
try:
cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)],
capture_output=True, text=True, timeout=10).stdout.strip()
except Exception:
continue
if cmd:
out.append((pid, cmd))
return out
def _plausible_id(uuid: str) -> bool:
"""An id safe to build a path from and to search for: not empty, not
made of dots alone ("." names the parent dir itself), no "/", ".." or "*"."""
@@ -354,14 +382,25 @@ class BaseAgentAdapter:
path = self.artifact_path(uuid, ctx)
return uuid in path and os.path.exists(path)
def in_use_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
"""On-disk paths checked with lsof for conversation_in_use and in_use_hints (#78)."""
return [self.artifact_path(uuid, ctx)]
def _holder_counts(self, cmd: str) -> bool:
return True
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
"""True while a live process still references the conversation."""
return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)])
return conversation_in_use(uuid, self.in_use_paths(uuid, ctx))
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Where to look for what holds the conversation, shown when a purge
is refused because it is in use."""
return [f"any process with it on its command line (an editor, a grep, ...): ps -axww | grep {uuid}"]
hints = [f"any process with it on its command line (an editor, a grep, ...): ps -axww | grep {uuid}"]
for pid, cmd in file_holders(self.in_use_paths(uuid, ctx)):
if self._holder_counts(cmd):
hints.append(f"held open by pid {pid}: {cmd} (not stopped by MAM; end it yourself if it is safe)")
return hints
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
raise NotImplementedError