diff --git a/.agents/skills/lib_py/workspace_uuid.py b/.agents/skills/lib_py/workspace_uuid.py index d86de22..b5665d5 100644 --- a/.agents/skills/lib_py/workspace_uuid.py +++ b/.agents/skills/lib_py/workspace_uuid.py @@ -15,17 +15,10 @@ OWN_KEY = { from lib_py.paths import resolve_home -def find_workspace_uuid_main(): - ws = os.environ['WS_ABS'] - agent = os.environ['AGENT'] - home = resolve_home() - claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects") - target = os.environ.get('TARGET_SESSION', '') - - try: - d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) - except Exception: - d = {} +def find_workspace_uuid(ws, agent, target='', state=None, home=None, claude_dir=None, exclude_ids=()): + d = state if isinstance(state, dict) else {} + home = resolve_home(home) + claude_project_dir = claude_dir or os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects") running_ids = set() for s_item in d.get('herdr_sessions', []): @@ -38,6 +31,10 @@ def find_workspace_uuid_main(): running_ids.add(val) orchestrator_ids = set(mam_orchestrator_uuids()) + if "MAM_ORCHESTRATOR_UUIDS" not in os.environ: + raw_orc = d.get('orchestrator_uuids') + if isinstance(raw_orc, list) and all(isinstance(x, str) and x.strip() for x in raw_orc): + orchestrator_ids |= set(raw_orc) if target: for s_item in d.get('herdr_sessions', []): if s_item.get('name') == target: @@ -45,11 +42,7 @@ def find_workspace_uuid_main(): if own: orchestrator_ids.discard(own) - def emit(u): - if u in running_ids or u in orchestrator_ids: - return - print(u) - sys.exit(0) + excluded = running_ids | orchestrator_ids | {u for u in (exclude_ids or ()) if u} # Fetch all sessions matching this workspace sessions = [] @@ -63,15 +56,17 @@ def find_workspace_uuid_main(): if s.get('name') != target: continue cand = s.get(OWN_KEY.get(agent, ''), None) - if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): - emit(cand) + if cand and verify_session_uuid(ws, agent, cand, s, home_dir=home, claude_dir=claude_project_dir, mode="revalidate"): + if cand not in excluded: + return cand for s in sessions: name = s.get('name', '') if name.endswith(f"-creator-{agent}"): cand = s.get(OWN_KEY.get(agent, '')) - if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): - emit(cand) + if cand and verify_session_uuid(ws, agent, cand, s, home_dir=home, claude_dir=claude_project_dir, mode="revalidate"): + if cand not in excluded: + return cand from lib_py.agents.registry import get_adapter from lib_py.agents.base import DiscoveryContext @@ -80,9 +75,25 @@ def find_workspace_uuid_main(): if adapter: ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home, claude_dir=claude_project_dir) for cand in adapter.discover(ctx): - emit(cand) + if cand and cand not in excluded: + return cand - print('') + return '' + + +def find_workspace_uuid_main(): + ws = os.environ['WS_ABS'] + agent = os.environ['AGENT'] + home = resolve_home() + claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects") + target = os.environ.get('TARGET_SESSION', '') + + try: + d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) + except Exception: + d = {} + + print(find_workspace_uuid(ws, agent, target, d, home, claude_project_dir)) if __name__ == '__main__': find_workspace_uuid_main() diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py new file mode 100644 index 0000000..3e54ac0 --- /dev/null +++ b/tests/test_issue63_same_cwd_ids.py @@ -0,0 +1,147 @@ +"""Issue #63: same-cwd conversation-id resolution in stop/purge/resume and +same-type creation ordering for post-discovered agents. +""" +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys +import time +from pathlib import Path +from typing import List, Optional + +import pytest +import yaml + +REPO = Path(__file__).resolve().parents[1] +SKILLS = REPO / ".agents" / "skills" +if str(SKILLS) not in sys.path: + sys.path.insert(0, str(SKILLS)) + +UA = "a6300001-0000-4000-8000-000000000001" +UB = "b6300002-0000-4000-8000-000000000002" +UC = "c6300003-0000-4000-8000-000000000003" +UX = "d6300004-0000-4000-8000-000000000004" + +pytestmark = pytest.mark.usefixtures("own_workspace_root") + + +# --------------------------------------------------------------------------- helpers + +def _agy_conv(home: Path, uuid: str, mtime: Optional[float] = None) -> Path: + db = Path(home) / ".gemini" / "antigravity-cli" / "conversations" / f"{uuid}.db" + db.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db)) + conn.execute("CREATE TABLE steps (id INTEGER)") + conn.execute("INSERT INTO steps VALUES (1)") + conn.commit() + conn.close() + if mtime is not None: + os.utime(db, (mtime, mtime)) + return db + + +def _agy_full_artifacts(home: Path, uuid: str) -> List[Path]: + base = Path(home) / ".gemini" / "antigravity-cli" + db = _agy_conv(home, uuid) + wal = Path(str(db) + "-wal") + shm = Path(str(db) + "-shm") + wal.write_text("wal") + shm.write_text("shm") + brain = base / "brain" / uuid + brain.mkdir(parents=True, exist_ok=True) + (brain / "note.md").write_text("note") + ann = base / "annotations" / f"{uuid}.pbtxt" + ann.parent.mkdir(parents=True, exist_ok=True) + ann.write_text("pbtxt") + lock = base / "presence" / f"{uuid}.lock" + lock.parent.mkdir(parents=True, exist_ok=True) + lock.write_text("") + return [db, wal, shm, brain, ann, lock] + + +def _agy_cache(home: Path, mapping: dict) -> None: + lc = Path(home) / ".gemini" / "antigravity-cli" / "cache" / "last_conversations.json" + lc.parent.mkdir(parents=True, exist_ok=True) + lc.write_text(json.dumps({str(k): v for k, v in mapping.items()})) + + +def _claude_transcript(claude_dir: Path, ws: Path, uuid: str, mtime: Optional[float] = None) -> Path: + from lib_py.verify_session import workspace_key + pdir = Path(claude_dir) / workspace_key(str(ws)) + pdir.mkdir(parents=True, exist_ok=True) + f = pdir / f"{uuid}.jsonl" + f.write_text(json.dumps({"sessionId": uuid, "cwd": str(ws), "type": "user"}) + "\n") + if mtime is not None: + os.utime(f, (mtime, mtime)) + return f + + +def _row(name: str, agent: str, cwd: Path, epoch: int, pid: int = 4242, **extra) -> dict: + own = {"agy": "agy_conversation_id_own", "opencode": "opencode_session_id_own", + "codex": "codex_session_id_own", "hermes": "hermes_conversation_id_own", + "claude": "claude_session_id_own", "grok": "grok_session_id_own"}[agent] + row = {"name": name, "status": "running", "role": "creator", "agent": agent, + "herdr_session_epoch": epoch, own: None, + "session_id_source": "pending-discovery", "session_id_verified": False, + "last_visible_status": "unverified", + "pane": {"cwd": str(cwd), "pid": pid, "cmd": agent}} + row.update(extra) + return row + + +# --------------------------------------------------------------------------- 2.1 workspace_uuid + +def test_find_workspace_uuid_skips_exclude_ids(tmp_path): + """rev 2 (B): find_workspace_uuid skips exclude_ids across sibling rows and discover().""" + from lib_py.workspace_uuid import find_workspace_uuid + + home = tmp_path / "home" + claude_dir = home / ".claude" / "projects" + ws = tmp_path / "ws" + ws.mkdir(parents=True) + now = time.time() + _claude_transcript(claude_dir, ws, UA, mtime=now) + _claude_transcript(claude_dir, ws, UB, mtime=now - 10) + state = { + "herdr_sessions": [ + _row("ws-creator-claude", "claude", ws, int(now) - 100, status="stopped", + claude_session_id_own=UA, session_id_source="discovered", session_id_verified=True), + _row("ws-reviewer-claude", "claude", ws, int(now) - 100, status="running", + claude_session_id_own=None), + ] + } + assert find_workspace_uuid(str(ws), "claude", "ws-reviewer-claude", state, + str(home), str(claude_dir), exclude_ids=()) == UA + assert find_workspace_uuid(str(ws), "claude", "ws-reviewer-claude", state, + str(home), str(claude_dir), exclude_ids={UA}) == UB + + +def test_find_workspace_uuid_uses_explicit_home_and_claude_dir(tmp_path, monkeypatch): + """rev 2 (B-sub): explicit home and claude_dir are forwarded to tier-1 revalidate.""" + from lib_py.workspace_uuid import find_workspace_uuid + + monkeypatch.setenv("HOME", str(tmp_path / "wrong-home")) + monkeypatch.delenv("HOME_DIR", raising=False) + monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False) + + home = tmp_path / "custom-home" + claude_dir = tmp_path / "custom-claude" + ws = tmp_path / "ws" + ws.mkdir(parents=True) + _agy_conv(home, UA) + _claude_transcript(claude_dir, ws, UB) + state = { + "herdr_sessions": [ + _row("ws-creator-agy", "agy", ws, 100, status="stopped", + agy_conversation_id_own=UA, session_id_source="discovered", session_id_verified=True), + _row("ws-creator-claude", "claude", ws, 100, status="stopped", + claude_session_id_own=UB, session_id_source="discovered", session_id_verified=True), + ] + } + assert find_workspace_uuid(str(ws), "agy", "ws-creator-agy", state, + home=str(home), claude_dir=str(claude_dir)) == UA + assert find_workspace_uuid(str(ws), "claude", "ws-creator-claude", state, + home=str(home), claude_dir=str(claude_dir)) == UB