352 lines
14 KiB
Python
352 lines
14 KiB
Python
"""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 "<unnamed>")
|
|
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())
|