Files
multi-agent-mux/.agents/skills/lib_py/agents/adapters/agy.py
T
GodopuandClaude Opus 5.5 feccfd5e03 fix(agents): pin post-discovered session ids on a verified completed; agy never takes another workspace's or session's conversation (Issue #40)
- 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>
2026-10-20 23:40:03 +09:00

275 lines
12 KiB
Python

import os, sys, json, shutil, subprocess
from typing import Optional, Any, Dict, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, capture_result, conversation_in_use
from lib_py.verify_session import verify_session_uuid, workspace_key
def _holder_pids(paths: List[str]) -> Optional[List[int]]:
"""PIDs holding any of `paths` open (lsof -t), [] when none does, None
when lsof cannot answer (missing, failed, timed out)."""
existing = [p for p in paths if p and os.path.exists(p)]
if not existing:
return []
lsof = shutil.which("lsof")
if not lsof:
return None
try:
r = subprocess.run([lsof, "-t"] + existing, capture_output=True, text=True, timeout=10)
except Exception:
return None
pids = sorted({int(t) for t in r.stdout.split() if t.isdigit()})
# lsof exits 1 both when nothing holds the files and on errors.
if r.returncode not in (0, 1) or (r.returncode == 1 and not pids and r.stderr.strip()):
return None
return pids
def _process_parents() -> Optional[Dict[int, int]]:
"""pid -> ppid of every running process (ps), None when ps fails."""
try:
out = subprocess.run(["ps", "-eo", "pid=,ppid="], capture_output=True, text=True, timeout=10)
except Exception:
return None
if out.returncode != 0:
return None
parents: Dict[int, int] = {}
for line in out.stdout.splitlines():
parts = line.split()
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
parents[int(parts[0])] = int(parts[1])
return parents or None
class AgyAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
return 'agy'
@property
def own_key(self) -> str:
return 'agy_conversation_id_own'
@property
def ready_tokens(self) -> str:
return 'Antigravity'
@property
def exit_key(self) -> str:
return '/exit'
@property
def delegate_agent_key(self) -> str:
return 'antigravity-cli'
@property
def startup_dialogs(self) -> list:
# #24: "Do you trust the contents of this project?" with
# "> Yes, I trust this folder" preselected - Enter accepts.
return [
('Do you trust the contents of this project|Yes, I trust this folder',
['Enter'], 'trust'),
]
@property
def identity_cache_fields(self) -> tuple:
return ('conversation_id', 'conversation_db', 'conversation_brain_dir')
@property
def input_prompt(self) -> str:
return '>'
@property
def input_placeholder(self) -> str:
return ''
@property
def input_rule_pattern(self) -> str:
return '─{10,}'
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return f"{ctx.home_dir}/.gemini/antigravity-cli/conversations/{uuid}.db"
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
path = self.artifact_path(uuid, ctx)
if not os.path.exists(path):
return False
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
return False
if ctx.mode == "discover":
# Issue #40: the cache maps each workspace to the conversation last
# opened there, so an id it maps to another workspace is that
# workspace's; and an id another row records is never this row's,
# whether or not the cache names it for this cwd.
here = workspace_key(ctx.cwd)
if any(v == uuid and workspace_key(k) != here for k, v in self._last_conversations(ctx).items()):
return False
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
return False
try:
conn = _ro_connect(path)
r = conn.execute("SELECT count(*) FROM steps").fetchone()
conn.close()
if not r or r[0] < 1:
return False
except Exception:
return False
return True
def _cli_root(self, ctx: DiscoveryContext) -> str:
return f"{ctx.home_dir}/.gemini/antigravity-cli"
def _last_conversations(self, ctx: DiscoveryContext) -> dict:
"""cache/last_conversations.json: {cwd: id of the conversation last opened there}."""
try:
with open(f"{self._cli_root(ctx)}/cache/last_conversations.json") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def capture_active_session_id(self, ctx: DiscoveryContext) -> dict:
"""Issue #40: the cache only proposes an id - it holds one id per cwd,
rewritten whenever any agy opens a conversation there, so it cannot tell
two sessions in one cwd apart. The id is this session's only when its
presence lock (or DB) is held open by the row's pane process or one of
its descendants. Anything unprovable is not pinned (no cache-only fallback)."""
here = workspace_key(ctx.cwd)
proposed = sorted({v for k, v in self._last_conversations(ctx).items()
if v and workspace_key(k) == here})
if not proposed:
return capture_result("no-candidate", reason="last_conversations.json names no conversation for this cwd")
if len(proposed) > 1:
return capture_result("ambiguous", reason="last_conversations.json maps this cwd to several conversations",
candidates=proposed)
uuid = proposed[0]
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
return capture_result("sibling-claimed", reason=f"{uuid} is recorded by another session", candidates=[uuid])
if not verify_session_uuid(ctx.cwd, self.name, uuid, row=ctx.row, home_dir=ctx.home_dir, mode="discover"):
return capture_result("rejected", reason=f"{uuid} failed verification (missing, older than the session, or empty)",
candidates=[uuid])
status, reason = self._owner_check(uuid, ctx)
if status != "ok":
return capture_result(status, reason=reason, candidates=[uuid])
return capture_result("captured", session_id=uuid, candidates=[uuid])
def _owner_check(self, uuid: str, ctx: DiscoveryContext):
"""('ok', '') when a holder of the conversation's presence lock or DB is
the row's pane process or its descendant; otherwise
('unverifiable-owner' | 'owner-mismatch', reason)."""
try:
pane_pid = int((ctx.row.get("pane") or {}).get("pid") or 0)
except (TypeError, ValueError):
pane_pid = 0
if pane_pid <= 1:
return "unverifiable-owner", "the row records no pane pid"
db = self.artifact_path(uuid, ctx)
paths = [f"{self._cli_root(ctx)}/presence/{uuid}.lock", db, f"{db}-wal", f"{db}-shm"]
holders = _holder_pids(paths)
if holders is None:
return "unverifiable-owner", "lsof is unavailable or failed"
if not holders:
return "unverifiable-owner", f"no process holds {uuid}'s presence lock or DB"
if pane_pid in holders:
return "ok", ""
parents = _process_parents()
if parents is None or pane_pid not in parents:
return "unverifiable-owner", f"pane process {pane_pid} cannot be found"
for pid in holders:
seen = set()
while pid > 1 and pid not in seen:
if pid == pane_pid:
return "ok", ""
seen.add(pid)
pid = parents.get(pid, 0)
return "owner-mismatch", f"{uuid} is held by pid(s) {holders}, none in pane process {pane_pid}'s tree"
def _artifact_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Every on-disk piece of one conversation, DB first: the SQLite
sidecars, the brain dir, the title annotation and the presence lock."""
db = self.artifact_path(uuid, ctx)
root = self._cli_root(ctx)
return ([db] + [f"{db}{suffix}" for suffix in ("-wal", "-shm", "-journal")]
+ [f"{root}/brain/{uuid}", f"{root}/annotations/{uuid}.pbtxt",
f"{root}/presence/{uuid}.lock"])
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
# Any piece counts, not only the DB: a purge that was interrupted (or
# the pre-#10 purge) can leave a WAL, annotation or lock behind with
# 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 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))
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #10: a purge must leave nothing resumable behind. Besides the
# conversation DB and the brain dir, agy keeps SQLite sidecars (-wal /
# -shm / -journal, created whenever the DB is opened - the stop path
# itself does so while capturing the id, and an unflushed WAL can still
# hold conversation content), the title annotation and a presence lock.
purged = []
db = self.artifact_path(uuid, ctx)
for path in [db] + [f"{db}{suffix}" for suffix in ("-wal", "-shm", "-journal")]:
if os.path.exists(path):
os.remove(path)
purged.append(path)
root = self._cli_root(ctx)
brain = f"{root}/brain/{uuid}"
if os.path.isdir(brain):
shutil.rmtree(brain, ignore_errors=True)
purged.append(brain)
annotation = f"{root}/annotations/{uuid}.pbtxt"
if os.path.exists(annotation):
os.remove(annotation)
purged.append(annotation)
# The presence lock marks a conversation some process has open; only
# remove it once nothing references the conversation any more.
lock = f"{root}/presence/{uuid}.lock"
if os.path.exists(lock):
if self.conversation_in_use(uuid, ctx):
print(f"WARN: presence lock kept, conversation {uuid} is still in use: {lock}", file=sys.stderr)
else:
os.remove(lock)
purged.append(lock)
return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Anything under the agy CLI tree still carrying this conversation id."""
root = self._cli_root(ctx)
if not os.path.isdir(root):
return []
found = []
for dirpath, dirnames, filenames in os.walk(root):
for name in dirnames + filenames:
if uuid in name:
found.append(os.path.join(dirpath, name))
# Do not descend into a matched directory; it is reported as a whole.
dirnames[:] = [d for d in dirnames if uuid not in d]
return sorted(found)
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --dangerously-skip-permissions"
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
if materialized and session_uuid:
return f"{binary} --dangerously-skip-permissions --conversation {session_uuid}"
return f"{binary} --dangerously-skip-permissions"
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
return os.path.exists(f"{home}/.gemini/oauth_creds.json") or os.path.exists(f"{home}/.gemini/antigravity-cli/antigravity-oauth-token")
def discover(self, ctx: DiscoveryContext) -> list:
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
if os.path.exists(lc):
try:
with open(lc) as f:
cand = json.load(f).get(ctx.workspace)
if cand and self.verify_artifact(cand, ctx):
return [cand]
except Exception:
pass
return []