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>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-20 23:40:03 +09:00
1 parent ff8b381520
commit feccfd5e03
19 files changed
+1128 -338

No files matched your search

+114 -15
View File
@@ -1,6 +1,43 @@
import os, sys, json, shutil
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, conversation_in_use
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
@@ -58,18 +95,15 @@ class AgyAgentAdapter(BaseAgentAdapter):
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
return False
if ctx.mode == "discover":
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
cache_match = False
if os.path.exists(lc):
try:
with open(lc) as f:
lc_data = json.load(f)
cache_match = (lc_data.get(ctx.cwd) == uuid)
except Exception:
cache_match = False
if not cache_match:
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
return False
# 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()
@@ -83,6 +117,71 @@ class AgyAgentAdapter(BaseAgentAdapter):
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."""