"""Stop, purge, and resume conversation-id decisions (Issue #63). Post-discovered agents (agy, codex, hermes, opencode) write their conversation id to disk only after their first turn, and agy's workspace cache holds one id per cwd. Stop, purge, and resume therefore use the row's own recorded id first (never falling through to a workspace guess when a recorded id is present), and otherwise use the #40 active-session capture when herdr is alive. Pre-assigned agents (claude, grok) keep find_workspace_uuid with other rows' claimed ids excluded. """ from __future__ import annotations import argparse import os import shlex import sys from typing import Any, Dict, List, Optional from lib_py.agents.autopin import OWN_KEYS, capture, claimed_elsewhere from lib_py.agents.base import DiscoveryContext, POST_DISCOVERED from lib_py.agents.registry import agent_of_row, get_adapter from lib_py.paths import resolve_home from lib_py.state import load_state_json from lib_py.verify_session import verify_session_uuid from lib_py.workspace_uuid import find_workspace_uuid def canonical_cwd(path: str) -> str: if not path: return "" try: return os.path.realpath(path) except Exception: return os.path.abspath(path) def own_id(row: Optional[Dict[str, Any]], agent: str) -> str: adapter = get_adapter(agent) if not adapter or not isinstance(row, dict): return "" val = row.get(adapter.own_key) return str(val).strip() if val else "" def claiming_rows(row: Dict[str, Any], uuid: str, state: Dict[str, Any]) -> List[str]: if not uuid: return [] me = row.get("name") holders: List[str] = [] for other in state.get("herdr_sessions") or []: if other is row or (me and other.get("name") == me): continue if any(other.get(k) == uuid for k in OWN_KEYS): other_name = str(other.get("name") or "") if other_name not in holders: holders.append(other_name) return holders def same_type_rows(state: Dict[str, Any], cwd: str, agent: str) -> List[Dict[str, Any]]: if not cwd or not agent: return [] target_cwd = canonical_cwd(cwd) out: List[Dict[str, Any]] = [] for s in state.get("herdr_sessions") or []: if not isinstance(s, dict): continue s_cwd = (s.get("pane") or {}).get("cwd") or "" if s_cwd and canonical_cwd(s_cwd) == target_cwd and agent_of_row(s) == agent: out.append(s) return out def row_for_capture(row: Optional[Dict[str, Any]], pane_pid: Optional[int], cwd: str = "", name: str = "", agent: str = "") -> Dict[str, Any]: copy = dict(row) if isinstance(row, dict) else {} if name and not copy.get("name"): copy["name"] = name if agent and not copy.get("agent"): copy["agent"] = agent pane = dict(copy.get("pane") or {}) if cwd and not pane.get("cwd"): pane["cwd"] = cwd if isinstance(pane_pid, int) and pane_pid > 0: pane["pid"] = pane_pid copy["pane"] = pane return copy def _find_row(state: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]: if not name: return None for s in state.get("herdr_sessions") or []: if isinstance(s, dict) and s.get("name") == name: return s return None def _parse_pid(raw: Any) -> Optional[int]: if isinstance(raw, int): return raw if raw > 0 else None s = str(raw or "").strip() return int(s) if s.isdigit() and int(s) > 0 else None def decide_stop(state: Dict[str, Any], name: str, agent: str, cwd: str, herdr_alive: bool, pane_pid: Optional[int] = None, purge: bool = False, home: Optional[str] = None, claude_dir: Optional[str] = None) -> Dict[str, str]: home_resolved = resolve_home(home) claude_resolved = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR") or f"{home_resolved}/.claude/projects" row = _find_row(state, name) row_ref = row if row is not None else ({"name": name} if name else {}) claimed = set(claimed_elsewhere(row_ref, state)) adapter = get_adapter(agent) own = own_id(row, agent) if own: if own in claimed: others = ", ".join(claiming_rows(row_ref, own, state)) return { "captured": "", "status": "sibling-claimed", "purge_uuid": "", "note": f"{own} is also recorded by {others}; registry needs fixing", } if adapter and adapter.id_lifecycle == POST_DISCOVERED: if verify_session_uuid(cwd, agent, own, row, home_dir=home_resolved, claude_dir=claude_resolved, mode="revalidate"): return { "captured": own, "status": "already-set", "purge_uuid": own if purge else "", "note": "", } ctx = DiscoveryContext(workspace=cwd, agent_name=agent, home_dir=home_resolved, claude_dir=claude_resolved, row=row, mode="revalidate") can_purge = bool(purge and adapter.has_artifacts(own, ctx)) return { "captured": "", "status": "unverified-own", "purge_uuid": own if can_purge else "", "note": f"recorded id {own} no longer verifies; kept as is", } if adapter is None or adapter.id_lifecycle != POST_DISCOVERED: found = find_workspace_uuid(cwd, agent, name, state, home_resolved, claude_resolved, exclude_ids=claimed) if cwd else "" if found and found in claimed: others = ", ".join(claiming_rows(row_ref, found, state)) return { "captured": "", "status": "sibling-claimed", "purge_uuid": "", "note": f"{found} is also recorded by {others}; registry needs fixing", } return { "captured": found, "status": "resolved" if found else "no-candidate", "purge_uuid": found if purge else "", "note": "" if found else "nothing on disk yet", } if herdr_alive: cap_row = row_for_capture(row, _parse_pid(pane_pid), cwd=cwd, name=name, agent=agent) res = capture(cap_row, agent, state, home_dir=home_resolved) if res.get("status") == "captured" and res.get("session_id"): u = str(res["session_id"]) if u in claimed: others = ", ".join(claiming_rows(row_ref, u, state)) return { "captured": "", "status": "sibling-claimed", "purge_uuid": "", "note": f"{u} is also recorded by {others}; registry needs fixing", } return { "captured": u, "status": "captured", "purge_uuid": u if purge else "", "note": "", } st = str(res.get("status") or "no-candidate") reason = str(res.get("reason") or "") return { "captured": "", "status": st, "purge_uuid": "", "note": f"{st}: {reason}" if reason else st, } return { "captured": "", "status": "herdr-dead", "purge_uuid": "", "note": "herdr session was already gone, so the pane that owns the conversation could not be checked; no id recorded", } def decide_resume(state: Dict[str, Any], name: str, agent: str, workspace: str, herdr_alive: bool = False, pane_pid: Optional[int] = None, home: Optional[str] = None, claude_dir: Optional[str] = None) -> Dict[str, Any]: home_resolved = resolve_home(home) claude_resolved = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR") or f"{home_resolved}/.claude/projects" row = _find_row(state, name) adapter = get_adapter(agent) if row is None and not name and workspace: same = same_type_rows(state, workspace, agent) if len(same) == 1: row = same[0] name = str(row.get("name") or "") elif adapter is None or adapter.id_lifecycle != POST_DISCOVERED: creator = next( (s for s in same if str(s.get("name") or "").endswith(f"-creator-{agent}")), None, ) if creator is not None: row = creator name = str(row.get("name") or "") row_ref = row if row is not None else ({"name": name} if name else {}) claimed = set(claimed_elsewhere(row_ref, state)) own = own_id(row, agent) if own: if own in claimed: others = ", ".join(claiming_rows(row_ref, own, state)) return { "uuid": "", "drop_stale_id": False, "notes": [], "error": (f"ERROR: {name}'s recorded id {own} is also recorded by {others}; " "refusing to resume on a conversation another session claims (Issue #63). " "One of these rows holds the wrong id - fix the registry before resuming."), } if adapter and adapter.id_lifecycle == POST_DISCOVERED: if verify_session_uuid(workspace, agent, own, row, home_dir=home_resolved, claude_dir=claude_resolved, mode="revalidate"): return {"uuid": own, "drop_stale_id": False, "notes": [], "error": None} if herdr_alive: return {"uuid": own, "drop_stale_id": False, "notes": [], "error": None} note = (f"NOTE: {name}'s recorded {agent} id {own} no longer verifies " "(its conversation is missing or empty); starting a new conversation (Issue #63)") return {"uuid": "", "drop_stale_id": True, "notes": [note], "error": None} if adapter is None or adapter.id_lifecycle != POST_DISCOVERED: u = find_workspace_uuid(workspace, agent, name, state, home_resolved, claude_resolved, exclude_ids=claimed) if u and u in claimed: others = ", ".join(claiming_rows(row_ref, u, state)) return { "uuid": "", "drop_stale_id": False, "notes": [], "error": (f"ERROR: {name or agent}'s resolved id {u} is also recorded by {others}; " "refusing to resume on a conversation another session claims (Issue #63)."), } return {"uuid": u, "drop_stale_id": False, "notes": [], "error": None} if herdr_alive: cap_row = row_for_capture(row, _parse_pid(pane_pid), cwd=workspace, name=name, agent=agent) res = capture(cap_row, agent, state, home_dir=home_resolved) u = str(res["session_id"]) if res.get("status") == "captured" and res.get("session_id") else "" else: same = same_type_rows(state, workspace, agent) n = len(same) if name and all(r.get("name") != name for r in same): n += 1 if n >= 2: label = name or agent note = (f"NOTE: {label} has no recorded {agent} id and {n} {agent} sessions share " f"{workspace}; the cache cannot tell them apart, so it starts a new conversation (Issue #63)") return {"uuid": "", "drop_stale_id": False, "notes": [note], "error": None} u = find_workspace_uuid(workspace, agent, name, state, home_resolved, claude_resolved, exclude_ids=claimed) if u and u in claimed: others = ", ".join(claiming_rows(row_ref, u, state)) return { "uuid": "", "drop_stale_id": False, "notes": [], "error": (f"ERROR: {name or agent}'s resolved id {u} is also recorded by {others}; " "refusing to resume on a conversation another session claims (Issue #63)."), } return {"uuid": u, "drop_stale_id": False, "notes": [], "error": None} def main(argv: Optional[List[str]] = None) -> int: p = argparse.ArgumentParser(prog="python -m lib_py.agents.session_ids") sub = p.add_subparsers(dest="cmd", required=True) s_stop = sub.add_parser("stop") s_stop.add_argument("--session", required=True) s_stop.add_argument("--agent", required=True) s_stop.add_argument("--cwd", default="") s_stop.add_argument("--herdr-alive", default="0") s_stop.add_argument("--pane-pid", default="") s_stop.add_argument("--purge", default="0") s_res = sub.add_parser("resume") s_res.add_argument("--session", default="") s_res.add_argument("--agent", required=True) s_res.add_argument("--workspace", required=True) s_res.add_argument("--herdr-alive", default="0") s_res.add_argument("--pane-pid", default="") s_res.add_argument("--shell", action="store_true") a = p.parse_args(argv) state = load_state_json() q = shlex.quote if a.cmd == "stop": res = decide_stop( state=state, name=a.session, agent=a.agent, cwd=a.cwd, herdr_alive=(a.herdr_alive == "1"), pane_pid=_parse_pid(a.pane_pid), purge=(a.purge == "1"), ) print(f"CAPTURED_UUID={q(res['captured'])}") print(f"CAPTURE_STATUS={q(res['status'])}") print(f"PURGE_UUID={q(res['purge_uuid'])}") print(f"STOP_ID_NOTE={q(res['note'])}") return 0 res = decide_resume( state=state, name=a.session, agent=a.agent, workspace=a.workspace, herdr_alive=(a.herdr_alive == "1"), pane_pid=_parse_pid(a.pane_pid), ) for note in res.get("notes") or []: print(note, file=sys.stderr) if res.get("error"): print(res["error"], file=sys.stderr) return 1 if a.shell: print(f"RESUME_UUID={q(res['uuid'])}") print(f"DROP_STALE_ID={'1' if res.get('drop_stale_id') else '0'}") else: print(res["uuid"]) return 0 if __name__ == "__main__": sys.exit(main())