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:
1 parent
ff8b381520
commit
feccfd5e03
19 files changed
+1128
-338
No files matched your search
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user