From ead490bddc0fc997e573601caa728b1c99acf63c Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 15:27:05 +0000 Subject: [PATCH 01/14] refactor(workspace_uuid): find_workspace_uuid() returns the id and skips exclude_ids; main prints it (Issue #63) --- .agents/skills/lib_py/workspace_uuid.py | 55 +++++---- tests/test_issue63_same_cwd_ids.py | 147 ++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 tests/test_issue63_same_cwd_ids.py 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 -- 2.54.0 From b8a30d5b93e56f8b272e3dd362b5e02faa2ef63e Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 15:31:42 +0000 Subject: [PATCH 02/14] feat(agents): session_ids - the stop/purge/resume id decision (Issue #63) --- .agents/skills/lib.sh | 13 + .agents/skills/lib_py/agents/session_ids.py | 329 ++++++++++++++++++++ tests/test_issue63_same_cwd_ids.py | 103 ++++++ 3 files changed, 445 insertions(+) create mode 100644 .agents/skills/lib_py/agents/session_ids.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index daa38b1..5aee8c4 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2032,12 +2032,25 @@ PYEOF # resolver (per-row own id -> workspace-scoped disk scan), # so recording its result into the row before kill guarantees tier-1 on the next # resume. Pass session_name to prefer that specific row's recorded id. Always exits 0. +# Note (Issue #63): stop_session.sh no longer calls this directly (it uses mam_session_ids). # --------------------------------------------------------------------------- capture_conversation_id() { local agent="$1" workdir="$2" session_name="${3:-}" find_workspace_uuid "$workdir" "$agent" "$session_name" } +# --------------------------------------------------------------------------- +# mam_session_ids [args...] +# Issue #63: stop/purge/resume conversation-id decision (lib_py.agents.session_ids). +# --------------------------------------------------------------------------- +mam_session_ids() { + env_python "$AGENT_SESSIONS_YAML" "$@" <<'PYEOF' +import sys +from lib_py.agents.session_ids import main +sys.exit(main(sys.argv[1:])) +PYEOF +} + # --------------------------------------------------------------------------- # Session isolation — Universal Global Config (Option B, P3-1) # diff --git a/.agents/skills/lib_py/agents/session_ids.py b/.agents/skills/lib_py/agents/session_ids.py new file mode 100644 index 0000000..44e5335 --- /dev/null +++ b/.agents/skills/lib_py/agents/session_ids.py @@ -0,0 +1,329 @@ +"""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, workspace_key +from lib_py.workspace_uuid import find_workspace_uuid + + +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_key = workspace_key(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 workspace_key(s_cwd) == target_key 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) + 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 { + "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()) diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index 3e54ac0..9dbdfc0 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -145,3 +145,106 @@ def test_find_workspace_uuid_uses_explicit_home_and_claude_dir(tmp_path, monkeyp 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 + + +# --------------------------------------------------------------------------- 2.2 session_ids unit tests + +@pytest.fixture +def owners(monkeypatch): + """agy presence ownership without lsof/ps: {uuid: [holder pids]} and a + pid -> ppid table the test fills in.""" + from lib_py.agents.adapters import agy + held, parents = {}, {} + + def holders(paths): + if held.get("__lsof__") is None and "__lsof__" in held: + return None + return sorted({p for path in paths for u, pids in held.items() if u in path for p in pids}) + + monkeypatch.setattr(agy, "_holder_pids", holders) + monkeypatch.setattr(agy, "_process_parents", lambda: dict(parents) or None) + return held, parents + + +def test_session_ids_decide_stop_and_resume_unit(tmp_path, owners): + from lib_py.agents.session_ids import decide_resume, decide_stop + + home = tmp_path / "home" + ws = tmp_path / "ws" + ws.mkdir(parents=True) + now = time.time() + epoch = int(now) - 100 + _agy_conv(home, UA, mtime=now - 5) + _agy_conv(home, UB, mtime=now - 2) + _agy_cache(home, {ws: UA}) + held, parents = owners + held[UA] = [5001] + held[UB] = [6001] + parents.update({5001: 1, 6001: 1}) + + state = { + "herdr_sessions": [ + _row("ws-creator-agy", "agy", ws, epoch, pid=5001), + _row("ws-reviewer-agy", "agy", ws, epoch, pid=6001), + ] + } + res_a = decide_stop(state, "ws-creator-agy", "agy", str(ws), True, pane_pid=5001, purge=False, home=str(home)) + assert res_a["captured"] == UA and res_a["status"] == "captured" + + res_b = decide_stop(state, "ws-reviewer-agy", "agy", str(ws), True, pane_pid=6001, purge=False, home=str(home)) + assert res_b["captured"] == "" and res_b["status"] == "owner-mismatch" + + res_dead = decide_stop(state, "ws-reviewer-agy", "agy", str(ws), False, pane_pid=None, purge=True, home=str(home)) + assert res_dead["captured"] == "" and res_dead["purge_uuid"] == "" + assert "herdr session was already gone" in res_dead["note"] + + # Two same-type rows in one cwd and B has no own id -> fresh spawn with NOTE + state["herdr_sessions"][0]["status"] = "stopped" + state["herdr_sessions"][0]["agy_conversation_id_own"] = UA + state["herdr_sessions"][1]["status"] = "stopped" + r_b = decide_resume(state, "ws-reviewer-agy", "agy", str(ws), herdr_alive=False, home=str(home)) + assert r_b["uuid"] == "" and r_b["error"] is None and not r_b["drop_stale_id"] + assert any("2 agy sessions share" in n for n in r_b["notes"]) + + +def test_session_ids_own_id_priority_rules_unit(tmp_path): + """rev 2 (A): own id never falls through to tier-2; broken registry refuses even when DB is missing.""" + from lib_py.agents.session_ids import decide_resume, decide_stop + + home = tmp_path / "home" + ws = tmp_path / "ws" + ws.mkdir(parents=True) + epoch = int(time.time()) - 100 + # Missing UA.db on disk, but both rows claim UA + state_dup = { + "herdr_sessions": [ + _row("ws-creator-agy", "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row("ws-reviewer-agy", "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + ] + } + r_dup = decide_resume(state_dup, "ws-reviewer-agy", "agy", str(ws), herdr_alive=False, home=str(home)) + assert r_dup["error"] and "ws-creator-agy" in r_dup["error"] + assert r_dup["notes"] == [] + + s_dup = decide_stop(state_dup, "ws-reviewer-agy", "agy", str(ws), False, purge=True, home=str(home)) + assert s_dup["captured"] == "" and s_dup["purge_uuid"] == "" + assert "also recorded by ws-creator-agy" in s_dup["note"] + + # Single row with stale own id UX (missing UX.db) while cache has UC + _agy_conv(home, UC) + _agy_cache(home, {ws: UC}) + state_stale = { + "herdr_sessions": [ + _row("ws-creator-agy", "agy", ws, epoch, status="stopped", agy_conversation_id_own=UX, + session_id_source="discovered", session_id_verified=True), + ] + } + r_stale = decide_resume(state_stale, "ws-creator-agy", "agy", str(ws), herdr_alive=False, home=str(home)) + assert r_stale["uuid"] == "" and r_stale["drop_stale_id"] is True and r_stale["error"] is None + assert any(f"recorded agy id {UX} no longer verifies" in n for n in r_stale["notes"]) + + s_stale = decide_stop(state_stale, "ws-creator-agy", "agy", str(ws), False, purge=False, home=str(home)) + assert s_stale["captured"] == "" and "no longer verifies; kept as is" in s_stale["note"] + -- 2.54.0 From 3a2219454ef7f8598717735275143ab2f1310998 Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 15:36:33 +0000 Subject: [PATCH 03/14] fix(stop): record and purge only the row's own or a verified id; a dead herdr records none (Issue #63) --- .../scripts/stop_session.sh | 57 ++++--- tests/test_issue63_same_cwd_ids.py | 141 ++++++++++++++++++ 2 files changed, 178 insertions(+), 20 deletions(-) diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 10f5fe8..67aec7a 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -5,8 +5,8 @@ # [--reason ] [--purge-conversation] [--yes] # # 동작: 항상 graceful stop 입니다. send-keys 로 정상 종료를 유도하고 -# (미종료 시 SIGTERM → SIGKILL 폴백), kill 직전에 이 워크스페이스의 -# conversation id 를 row 에 확정 기록해 다음 resume 이 tier-1(race-free) +# (미종료 시 SIGTERM → SIGKILL 폴백), kill 직전에 이 세션(pane)의 검증된 +# conversation id 만 row 에 확정 기록해 다음 resume 이 tier-1(race-free) # 으로 복원되게 합니다. status 는 running -> stopped 로 전이합니다. # 멱등: 이미 stopped 면 no-op + exit 0. # @@ -198,12 +198,6 @@ if [ "$PURGE" = "1" ] && [ "$YES" != "1" ]; then exit 3 fi -# purge 대상 UUID 를 워크스페이스 격리해서 해결 (P0-C — 전역 참조 금지) -PURGE_UUID="" -if [ "$PURGE" = "1" ] && [ -n "$TARGET_CWD" ]; then - PURGE_UUID=$(find_workspace_uuid "$TARGET_CWD" "$AGENT" "$SESSION_NAME" || true) -fi - NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') NOW_EPOCH=$(date +%s) @@ -215,19 +209,25 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then LAST_STATUS=$(herdr capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true) fi -# 캡처: kill 직전에 conversation id 를 해결 (process/jsonl 이 아직 살아있을 때). -# find_workspace_uuid 가 tier-1(row) -> tier-2(workspace-scoped disk scan) -# 를 알아서 시도하므로 herdr 생사와 무관하게 동작. -CAPTURED_UUID="" -if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then - CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" "$SESSION_NAME" || true) - if [ -n "$CAPTURED_UUID" ]; then - echo "captured conversation id: $CAPTURED_UUID" - else - echo "WARN: no conversation id resolved before stop (nothing on disk yet)" - fi +# Issue #63: before the kill, decide the id this stop records and a purge +# deletes (lib_py.agents.session_ids): the row's own id; else, for an agent +# that writes its id after its first turn, only what the #40 capture proves is +# this pane's (nothing once herdr is dead - the reason goes on the row); claude +# and grok keep find_workspace_uuid. An id another row records is never used. +CAPTURED_UUID="" CAPTURE_STATUS="" PURGE_UUID="" STOP_ID_NOTE="" +if [ -n "$TARGET_CWD" ]; then + PANE_PID_NOW="" + [ "$HERDR_ALIVE" = "1" ] && PANE_PID_NOW=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true) + eval "$(mam_session_ids stop --session "$SESSION_NAME" --agent "$AGENT" --cwd "$TARGET_CWD" \ + --herdr-alive "$HERDR_ALIVE" --pane-pid "$PANE_PID_NOW" --purge "$PURGE")" || true + if [ -n "$CAPTURED_UUID" ]; then echo "captured conversation id: $CAPTURED_UUID" + else echo "WARN: no conversation id recorded before stop${STOP_ID_NOTE:+ - $STOP_ID_NOTE}"; fi fi + + +# (end of pre-kill id decision) + delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" # graceful 종료: send-keys 로 정상 종료 유도 → 폴백 체인 (SIGTERM → SIGKILL). @@ -304,7 +304,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" PURGE="$PURGE" \ NOW_ISO="$NOW_ISO" NOW_EPOCH="$NOW_EPOCH" LAST_STATUS="$LAST_STATUS" \ PURGE_UUID="$PURGE_UUID" TARGET_CWD="$TARGET_CWD" \ - REASON="$REASON" CAPTURED_UUID="$CAPTURED_UUID" <<'PYEOF' + REASON="$REASON" CAPTURED_UUID="$CAPTURED_UUID" CAPTURE_STATUS="$CAPTURE_STATUS" STOP_ID_NOTE="$STOP_ID_NOTE" <<'PYEOF' import shutil name = os.environ['SESSION_NAME'] agent = os.environ['AGENT'] @@ -326,6 +326,19 @@ if target is None: print(f"ERROR: disappeared during script: {name}", flush=True) raise SystemExit(1) +from lib_py.agents.autopin import claimed_elsewhere, pin_row +capture_status = os.environ.get('CAPTURE_STATUS', '').strip() +note = os.environ.get('STOP_ID_NOTE', '').strip() +claimed = set(claimed_elsewhere(target, d)) +if captured and captured in claimed: + print(f"WARN: refusing to record {captured} on {name}; another row already claims it", flush=True) + if not note: + note = f"{captured} is already claimed by another row" + captured = '' +if purge_uuid and purge_uuid in claimed: + print(f"WARN: refusing to purge {purge_uuid} for {name}; another row claims it", flush=True) + purge_uuid = '' + if not purge: target['status'] = 'stopped' target['stopped_at'] = now @@ -350,7 +363,11 @@ if captured and not purge: target['opencode_session_id_own'] = captured elif agent == 'codex': target['codex_session_id_own'] = captured + if capture_status == 'captured': + pin_row(target, agent, captured) target['resumable'] = True +elif not purge and note: + target['last_visible_status'] = f"stop: {note}" if purge and purge_uuid: from lib_py.agents.registry import get_adapter diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index 9dbdfc0..fe44f7d 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -248,3 +248,144 @@ def test_session_ids_own_id_priority_rules_unit(tmp_path): s_stale = decide_stop(state_stale, "ws-creator-agy", "agy", str(ws), False, purge=False, home=str(home)) assert s_stale["captured"] == "" and "no longer verifies; kept as is" in s_stale["note"] + +# --------------------------------------------------------------------------- Done Criteria 1 & 2 (stop & purge) + +def _fake_lsof_multi(bindir: Path, mapping: dict) -> None: + """Fake lsof that prints the pid from `mapping` ({uuid_substr: pid}) for any matching path.""" + cases = "\n".join(f' *{k}*) echo {v}; found=1 ;;' for k, v in mapping.items()) + exe = Path(bindir) / "lsof" + exe.write_text( + "#!/bin/sh\n" + "found=0\n" + 'for a in "$@"; do\n' + ' case "$a" in\n' + f"{cases}\n" + " esac\n" + "done\n" + '[ "$found" = "1" ] && exit 0\n' + "exit 1\n" + ) + exe.chmod(0o755) + + +def _mock_herdr_agent(state_file: Path, name: str, cwd: Path, pid: int, epoch: int, + agent: str = "agy", pane_id: str = "") -> None: + state = json.loads(Path(state_file).read_text()) + pid_str = pane_id or f"w1:p_{name}" + state.setdefault("agents", {})[name] = { + "pid": pid, + "cwd": str(cwd), + "command": agent, + "agent": agent, + "pane_id": pid_str, + "workspace_id": "w1", + "created": epoch, + "buffer": f"Antigravity {cwd}\n>", + } + Path(state_file).write_text(json.dumps(state)) + + +def _write_rows(sandbox: Path, rows: list) -> Path: + lib = sandbox / ".agents" / "skills" / "lib.sh" + yaml_path = sandbox / ".mam" / "agent-sessions.yaml" + mutation = f"d['herdr_sessions'] = {repr(rows)}\n" + res = subprocess.run( + ["bash", "-c", f'source "{lib}" && atomic_dump_yaml "{yaml_path}"'], + input=mutation, capture_output=True, text=True, cwd=str(sandbox), timeout=60, + ) + assert res.returncode == 0, res.stdout + res.stderr + return yaml_path + + +def _read_rows(sandbox: Path) -> dict: + yaml_path = sandbox / ".mam" / "agent-sessions.yaml" + data = yaml.safe_load(yaml_path.read_text()) or {} + return {r["name"]: r for r in data.get("herdr_sessions") or []} + + +def _run_stop(sandbox: Path, session: str, agent: str = "agy", purge: bool = False) -> subprocess.CompletedProcess: + script = sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + cmd = ["bash", str(script), "--session", session, "--agent", agent] + if purge: + cmd += ["--purge-conversation", "--yes"] + env = dict(os.environ, SKS_QUIESCENT_TRIES="0", SKS_DIALOG_TIMEOUT="0", SKS_BUSY_TIMEOUT="0") + return subprocess.run(cmd, capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=120) + + +@pytest.mark.parametrize("order", [("a_then_b", True), ("b_then_a", True), ("both_dead", False)]) +def test_c1_stop_same_cwd_agy_sessions_records_only_verified_owner(mam_sandbox, mock_herdr, order): + """Done Criterion 1: two agy sessions in one cwd; cache points to A's UA. + Stop A->B and B->A with herdr alive records UA on A and None on B; when + herdr is already dead for both, neither records an id and both leave a reason.""" + label, alive = order + ws = mam_sandbox / "same-ws" + ws.mkdir(parents=True, exist_ok=True) + now = time.time() + epoch = int(now) - 100 + _agy_full_artifacts(mam_sandbox, UA) + _agy_full_artifacts(mam_sandbox, UB) + _agy_cache(mam_sandbox, {ws: UA}) + + proc_a = subprocess.Popen(["sleep", "120"]) + proc_b = subprocess.Popen(["sleep", "120"]) + try: + _fake_lsof_multi(mam_sandbox / "bin", {UA: proc_a.pid, UB: proc_b.pid}) + name_a, name_b = "same-ws-creator-agy", "same-ws-reviewer-agy" + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch, pid=proc_a.pid), + _row(name_b, "agy", ws, epoch, pid=proc_b.pid), + ]) + if alive: + _mock_herdr_agent(mock_herdr, name_a, ws, proc_a.pid, epoch, pane_id="w1:p_a") + _mock_herdr_agent(mock_herdr, name_b, ws, proc_b.pid, epoch, pane_id="w1:p_b") + + seq = [name_a, name_b] if label != "b_then_a" else [name_b, name_a] + for sname in seq: + res = _run_stop(mam_sandbox, sname, "agy") + assert res.returncode == 0, res.stdout + res.stderr + + rows = _read_rows(mam_sandbox) + if alive: + assert rows[name_a]["agy_conversation_id_own"] == UA + assert rows[name_a]["resumable"] is True + assert rows[name_b]["agy_conversation_id_own"] is None + assert rows[name_b]["last_visible_status"].startswith("stop: ") + else: + assert rows[name_a]["agy_conversation_id_own"] is None + assert rows[name_b]["agy_conversation_id_own"] is None + assert "herdr session was already gone" in rows[name_a]["last_visible_status"] + assert "herdr session was already gone" in rows[name_b]["last_visible_status"] + finally: + for p in (proc_a, proc_b): + p.kill() + p.wait() + + +@pytest.mark.parametrize("broken_dup", [False, True]) +def test_c2_purge_never_deletes_another_rows_conversation(mam_sandbox, mock_herdr, broken_dup): + """Done Criterion 2: A is stopped with UA; B has no own id (or also has UA in a + broken registry) and herdr is dead. Purging B leaves A's conversation intact.""" + ws = mam_sandbox / "same-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + a_files = _agy_full_artifacts(mam_sandbox, UA) + _agy_cache(mam_sandbox, {ws: UA}) + + name_a, name_b = "same-ws-creator-agy", "same-ws-reviewer-agy" + row_a = _row(name_a, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True) + row_b = _row(name_b, "agy", ws, epoch, status="running", + agy_conversation_id_own=(UA if broken_dup else None)) + _write_rows(mam_sandbox, [row_a, row_b]) + + res = _run_stop(mam_sandbox, name_b, "agy", purge=True) + assert res.returncode == 0, res.stdout + res.stderr + assert "nothing purged" in res.stdout + for p in a_files: + assert p.exists(), f"A's artifact was deleted by B's purge: {p}" + rows = _read_rows(mam_sandbox) + assert name_b not in rows + assert rows[name_a]["agy_conversation_id_own"] == UA + + -- 2.54.0 From fa93cc562284e4510be6d6bf7544e25d557c2dd4 Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 15:49:24 +0000 Subject: [PATCH 04/14] fix(resume): no guess with two same-type rows in a cwd; refuse another row's id before spawning; the running branch uses the same decision (Issue #63) --- .../scripts/resolve_session_id.sh | 22 +- .../scripts/resume_session.sh | 50 ++-- .../scripts/update_yaml_resumed.sh | 21 +- tests/test_issue63_same_cwd_ids.py | 224 ++++++++++++++++++ 4 files changed, 290 insertions(+), 27 deletions(-) diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh b/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh index 16b4978..3d9484d 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh @@ -4,9 +4,9 @@ # bash resolve_session_id.sh --workspace --agent # 출력: stdout 으로 UUID 한 줄 (없으면 빈 줄 + exit 0) # -# P0-C: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::find_workspace_uuid -# 가 워크스페이스 격리된 해결 경로(per-row own id -> 디스크 스캔 -> cwd 일치하는 -# cache)만 사용. 다른 워크스페이스의 UUID 를 절대 반환하지 않음. +# P0-C / Issue #63: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::mam_session_ids +# 가 워크스페이스 격리된 해결 경로(per-row own id -> #40 active capture / 디스크 스캔)만 사용. +# 다른 워크스페이스나 다른 행이 기록한 UUID 를 절대 반환하지 않음. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" @@ -22,13 +22,17 @@ EOF WORKSPACE="" AGENT="" SESSION_NAME="" +HERDR_ALIVE="0" +PANE_PID="" while [ $# -gt 0 ]; do case "$1" in - --workspace) WORKSPACE="$2"; shift 2 ;; - --agent) AGENT="$2"; shift 2 ;; - --session) SESSION_NAME="$2"; shift 2 ;; - -h|--help) usage; exit 0 ;; + --workspace) WORKSPACE="$2"; shift 2 ;; + --agent) AGENT="$2"; shift 2 ;; + --session) SESSION_NAME="$2"; shift 2 ;; + --herdr-alive) HERDR_ALIVE="$2"; shift 2 ;; + --pane-pid) PANE_PID="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac done @@ -40,4 +44,6 @@ case "$AGENT" in *) echo "ERROR: --agent must be claude, agy, hermes, grok, opencode, or codex" >&2; exit 2 ;; esac -find_workspace_uuid "$WORKSPACE" "$AGENT" "$SESSION_NAME" +mam_session_ids resume --session "$SESSION_NAME" --agent "$AGENT" \ + --workspace "$(mam_abs_workspace "$WORKSPACE")" \ + --herdr-alive "$HERDR_ALIVE" --pane-pid "$PANE_PID" diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 152ad82..6988234 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -107,9 +107,33 @@ if [ "$cp_rc" -ne 0 ] || [ -z "$CP_JSON" ]; then CP_JSON="" fi -# 1. Resolve the session id -UUID=$(bash "$(dirname "${BASH_SOURCE[0]}")/resolve_session_id.sh" \ - --workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME") +if [ -n "$HERDR_SERVER_OPT" ]; then + export HERDR_SESSION_NAME="$HERDR_SERVER_OPT" +else + HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")" + export HERDR_SESSION_NAME +fi + +if [ -n "$HERDR_WORKSPACE_OPT" ]; then + export MAM_WS_LABEL="$HERDR_WORKSPACE_OPT" +else + export MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")" +fi + +HERDR_ALIVE=0 +PANE_PID_NOW="" +if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then + HERDR_ALIVE=1 + PANE_PID_NOW=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true) +fi + +# 1. Resolve the session id (Issue #63: own id first, then #40 capture when +# herdr is alive, or fresh spawn when 2+ same-type rows share the cwd) +RESUME_OUT=$(mam_session_ids resume --shell \ + --workspace "$(mam_abs_workspace "$WORKSPACE")" --agent "$AGENT" --session "$SESSION_NAME" \ + --herdr-alive "$HERDR_ALIVE" --pane-pid "$PANE_PID_NOW") +eval "$RESUME_OUT" +UUID="$RESUME_UUID" # ISSUE-3: agy/hermes/opencode (Class A) are spawned without a UUID - their # own conversation id is only assigned by the agent itself on its first turn. @@ -130,21 +154,8 @@ if [ -z "$UUID" ]; then esac fi -if [ -n "$HERDR_SERVER_OPT" ]; then - export HERDR_SESSION_NAME="$HERDR_SERVER_OPT" -else - HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")" - export HERDR_SESSION_NAME -fi - -if [ -n "$HERDR_WORKSPACE_OPT" ]; then - export MAM_WS_LABEL="$HERDR_WORKSPACE_OPT" -else - export MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")" -fi - # 2. If herdr is alive, print warning or attach. -if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then +if [ "$HERDR_ALIVE" = "1" ]; then if [ "${DRY_RUN:-0}" = "1" ]; then echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate" exit 0 @@ -153,7 +164,7 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then # Just update YAML to make sure it's set to running CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ - --herdr-session "$HERDR_SESSION_NAME" \ + --herdr-session "$HERDR_SESSION_NAME" --already-running \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} exit 0 fi @@ -252,9 +263,12 @@ handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT" sleep 2 # 5. Update agent-sessions.yaml: status running, last_visible_status +DROP_FLAG="" +[ "${DROP_STALE_ID:-0}" = "1" ] && DROP_FLAG="--drop-stale-id" CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ + ${DROP_FLAG:+$DROP_FLAG} \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} echo "Successfully resumed $SESSION_NAME ($AGENT)" diff --git a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh index 57c5739..7b6f270 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh @@ -27,6 +27,8 @@ ROLE="" HERDR_SERVER_OPT="" HERDR_WORKSPACE_OPT="" CMD_FULL_OPT="" +ALREADY_RUNNING=0 +DROP_STALE_ID=0 while [ $# -gt 0 ]; do case "$1" in @@ -38,6 +40,8 @@ while [ $# -gt 0 ]; do --herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;; --herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;; --cmd-full) CMD_FULL_OPT="$2"; shift 2 ;; + --already-running) ALREADY_RUNNING=1; shift ;; + --drop-stale-id) DROP_STALE_ID=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac @@ -110,6 +114,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ NOW_EPOCH="$NOW_EPOCH" TARGET_WORKSPACE="${WORKSPACE:-$WORKSPACE_ROOT}" ROLE="$ROLE" \ PANE_PID="$PANE_PID" CHILD_PID="$CHILD_PID" HERDR_SERVER_OPT_EXPLICIT="${HERDR_SERVER_OPT_EXPLICIT:-0}" \ MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \ + ALREADY_RUNNING="$ALREADY_RUNNING" DROP_STALE_ID="$DROP_STALE_ID" \ CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF' name = os.environ['SESSION_NAME'] uuid = os.environ['UUID'] @@ -173,7 +178,17 @@ if _cp_raw and not isinstance(target.get('control_plane'), dict): target['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')} except Exception: pass -if not uuid: +stale_dropped = '' +if not uuid and os.environ.get('DROP_STALE_ID') == '1': + from lib_py.agents.autopin import claimed_elsewhere + from lib_py.agents.registry import get_adapter + _adapter = get_adapter(agent) + _own_key = _adapter.own_key if _adapter else '' + _old = (target.get(_own_key) or '').strip() if _own_key else '' + if _old and _old not in set(claimed_elsewhere(target, d)): + target[_own_key] = None + stale_dropped = _old +if not uuid and os.environ.get('ALREADY_RUNNING') != '1': # ISSUE-3 (challenge c4b0a075): a Class A fresh-spawn resume re-arms # discovery from scratch - the resumed agent has not spoken yet, so any # on-disk conversation older than THIS resume must not be auto-pinned to @@ -196,6 +211,10 @@ target.pop('stop_reason', None) target.pop('resumable', None) if uuid: target['last_visible_status'] = f'resumed conversation {uuid} at {now}' +elif os.environ.get('ALREADY_RUNNING') == '1': + target['last_visible_status'] = f'already running; no id recorded at {now}' +elif stale_dropped: + target['last_visible_status'] = f'resumed (fresh spawn, recorded id {stale_dropped} no longer verified) at {now}' else: # ISSUE-3 Class A fresh-spawn resume: no conversation id existed yet. target['last_visible_status'] = f'resumed (fresh spawn, no prior turn) at {now}' diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index fe44f7d..1d6ece5 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -389,3 +389,227 @@ def test_c2_purge_never_deletes_another_rows_conversation(mam_sandbox, mock_herd assert rows[name_a]["agy_conversation_id_own"] == UA +# --------------------------------------------------------------------------- Done Criteria 3, 4, 7 & rev 2 (resume) + +def _run_resume(sandbox: Path, ws: Path, session: str, agent: str = "agy", + dry_run: bool = False) -> subprocess.CompletedProcess: + script = sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh" + cmd = ["bash", str(script), "--workspace", str(ws), "--agent", agent, "--session", session] + if dry_run: + cmd.append("--dry-run") + return subprocess.run(cmd, capture_output=True, text=True, cwd=str(sandbox), timeout=120) + + +def test_c3_resume_unpinned_second_agy_row_starts_fresh_and_running_branch_keeps_epoch( + mam_sandbox, mock_herdr, mock_agents +): + """Done Criterion 3: two stopped agy rows in one cwd (A=UA, B=None): + dry-run and real resume of B spawn without --conversation UA and emit NOTE; + when B's herdr is already alive without an id, resume does not record UA and + preserves B's herdr_session_epoch.""" + ws = mam_sandbox / "same-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch_a = int(time.time()) - 300 + epoch_b = int(time.time()) - 200 + _agy_full_artifacts(mam_sandbox, UA) + _agy_cache(mam_sandbox, {ws: UA}) + + name_a, name_b = "same-ws-creator-agy", "same-ws-reviewer-agy" + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch_a, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row(name_b, "agy", ws, epoch_b, status="stopped", agy_conversation_id_own=None), + ]) + + dry = _run_resume(mam_sandbox, ws, name_b, "agy", dry_run=True) + assert dry.returncode == 0, dry.stdout + dry.stderr + assert "--conversation" not in dry.stdout + assert f"NOTE: {name_b} has no recorded agy id and 2 agy sessions share" in dry.stderr + + real = _run_resume(mam_sandbox, ws, name_b, "agy", dry_run=False) + assert real.returncode == 0, real.stdout + real.stderr + rows = _read_rows(mam_sandbox) + assert rows[name_b]["status"] == "running" + assert rows[name_b]["agy_conversation_id_own"] is None + assert "--conversation" not in rows[name_b]["pane"]["cmd_full"] + + # Running branch: B is already alive in herdr without an own id; epoch must stay fixed. + fixed_epoch = 1700000123 + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch_a, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row(name_b, "agy", ws, fixed_epoch, status="running", agy_conversation_id_own=None), + ]) + _mock_herdr_agent(mock_herdr, name_b, ws, 9999, fixed_epoch, pane_id="w1:p_b") + + alive_res = _run_resume(mam_sandbox, ws, name_b, "agy", dry_run=False) + assert alive_res.returncode == 0, alive_res.stdout + alive_res.stderr + rows = _read_rows(mam_sandbox) + assert rows[name_b]["agy_conversation_id_own"] is None + assert rows[name_b]["herdr_session_epoch"] == fixed_epoch + assert rows[name_b]["last_visible_status"].startswith("already running; no id recorded at ") + + +def test_c4_pinned_post_discovered_and_preassigned_agents_unchanged(mam_sandbox, mock_herdr, mock_agents): + """Done Criterion 4: agy/opencode rows with own_id keep it on stop and resume with it; + claude/grok stop/resume and find_workspace_uuid behave as before.""" + ws = mam_sandbox / "c4-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + _agy_full_artifacts(mam_sandbox, UA) + _claude_transcript(mam_sandbox / ".claude" / "projects", ws, UB) + + # Seed opencode DB for ses_c4 + oc_db = mam_sandbox / ".local" / "share" / "opencode" / "opencode.db" + oc_db.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(oc_db)) + conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, time_created INTEGER)") + conn.execute("INSERT OR REPLACE INTO session_v2 VALUES (?, NULL, ?, ?)", ("ses_c4", str(ws), epoch * 1000)) + conn.commit() + conn.close() + + _write_rows(mam_sandbox, [ + _row("c4-creator-agy", "agy", ws, epoch, status="running", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row("c4-creator-opencode", "opencode", ws, epoch, status="running", opencode_session_id_own="ses_c4", + session_id_source="discovered", session_id_verified=True), + _row("c4-creator-claude", "claude", ws, epoch, status="running", claude_session_id_own=UB, + session_id_source="assigned", session_id_verified=True), + ]) + + for sname, agent in (("c4-creator-agy", "agy"), ("c4-creator-opencode", "opencode"), ("c4-creator-claude", "claude")): + st = _run_stop(mam_sandbox, sname, agent) + assert st.returncode == 0, st.stdout + st.stderr + + rows = _read_rows(mam_sandbox) + assert rows["c4-creator-agy"]["agy_conversation_id_own"] == UA and rows["c4-creator-agy"]["resumable"] is True + assert rows["c4-creator-opencode"]["opencode_session_id_own"] == "ses_c4" and rows["c4-creator-opencode"]["resumable"] is True + assert rows["c4-creator-claude"]["claude_session_id_own"] == UB and rows["c4-creator-claude"]["resumable"] is True + + r_agy = _run_resume(mam_sandbox, ws, "c4-creator-agy", "agy", dry_run=True) + assert r_agy.returncode == 0 and f"--conversation {UA}" in r_agy.stdout + r_oc = _run_resume(mam_sandbox, ws, "c4-creator-opencode", "opencode", dry_run=True) + assert r_oc.returncode == 0 and "--session ses_c4" in r_oc.stdout + r_cl = _run_resume(mam_sandbox, ws, "c4-creator-claude", "claude", dry_run=True) + assert r_cl.returncode == 0 and f"-r {UB}" in r_cl.stdout + + +@pytest.mark.parametrize("db_exists", [True, False]) +def test_c7_resume_refuses_when_recorded_id_is_claimed_by_another_row( + mam_sandbox, mock_herdr, mock_agents, db_exists +): + """Done Criterion 7 & rev 2 (A): when both A and B record UA (whether UA.db + exists on disk or is missing), resume B refuses with exit 1 before spawning, + names A on stderr, and emits no fresh-spawn NOTE.""" + ws = mam_sandbox / "c7-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + if db_exists: + _agy_full_artifacts(mam_sandbox, UA) + _agy_cache(mam_sandbox, {ws: UA}) + + name_a, name_b = "c7-ws-creator-agy", "c7-ws-reviewer-agy" + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row(name_b, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + ]) + before = _read_rows(mam_sandbox) + + res = _run_resume(mam_sandbox, ws, name_b, "agy", dry_run=False) + assert res.returncode == 1, res.stdout + res.stderr + assert name_a in res.stderr and UA in res.stderr + assert "NOTE:" not in res.stderr + state = json.loads(Path(mock_herdr).read_text()) + assert name_b not in state.get("agents", {}) + assert _read_rows(mam_sandbox) == before + + +def test_rev2_stale_own_id_single_row_never_overwrites_with_cache_and_clears_on_resume( + mam_sandbox, mock_herdr, mock_agents +): + """rev 2 (A)-3: single agy row with stale own_id UX (UX.db missing) while cache + and disk hold UC. Stop keeps UX; resume starts a fresh conversation without UC, + emits NOTE, and clears agy_conversation_id_own to None so #40 can pin later.""" + ws = mam_sandbox / "stale-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + _agy_full_artifacts(mam_sandbox, UC) + _agy_cache(mam_sandbox, {ws: UC}) + + name = "stale-ws-creator-agy" + _write_rows(mam_sandbox, [ + _row(name, "agy", ws, epoch, status="running", agy_conversation_id_own=UX, + session_id_source="discovered", session_id_verified=True), + ]) + + st = _run_stop(mam_sandbox, name, "agy") + assert st.returncode == 0, st.stdout + st.stderr + row_after_stop = _read_rows(mam_sandbox)[name] + assert row_after_stop["agy_conversation_id_own"] == UX + assert "no longer verifies; kept as is" in row_after_stop["last_visible_status"] + + res = _run_resume(mam_sandbox, ws, name, "agy", dry_run=False) + assert res.returncode == 0, res.stdout + res.stderr + assert f"NOTE: {name}'s recorded agy id {UX} no longer verifies" in res.stderr + row_after_resume = _read_rows(mam_sandbox)[name] + assert row_after_resume["agy_conversation_id_own"] is None + assert row_after_resume["session_id_source"] == "pending-discovery" + assert row_after_resume["session_id_verified"] is False + assert f"recorded id {UX} no longer verified" in row_after_resume["last_visible_status"] + assert UC not in row_after_resume["pane"]["cmd_full"] + + +def test_rev2_drop_stale_id_refuses_to_clear_id_claimed_by_another_row(mam_sandbox): + """rev 2: update_yaml_resumed.sh --drop-stale-id keeps own_id if another row also claims it.""" + ws = mam_sandbox / "dup-drop-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + name_a, name_b = "dup-creator-agy", "dup-reviewer-agy" + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA), + _row(name_b, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA), + ]) + upd = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh" + res = subprocess.run( + ["bash", str(upd), "--session", name_b, "--uuid", "", "--agent", "agy", + "--workspace", str(ws), "--drop-stale-id"], + capture_output=True, text=True, cwd=str(mam_sandbox), timeout=60, + ) + assert res.returncode == 0, res.stdout + res.stderr + assert _read_rows(mam_sandbox)[name_b]["agy_conversation_id_own"] == UA + + +def test_rev2_claude_unpinned_sibling_row_skips_creator_claimed_id(mam_sandbox, mock_herdr, mock_agents): + """rev 2 (B): ws-creator-claude is stopped with UA; ws-reviewer-claude has no + own_id (--wrapper row shape); disk has UA.jsonl and UB.jsonl. Resume and stop + of ws-reviewer-claude pick UB instead of creator's UA.""" + ws = mam_sandbox / "claude-sib-ws" + ws.mkdir(parents=True, exist_ok=True) + now = time.time() + epoch = int(now) - 100 + cdir = mam_sandbox / ".claude" / "projects" + _claude_transcript(cdir, ws, UA, mtime=now) + _claude_transcript(cdir, ws, UB, mtime=now - 10) + + name_a, name_b = "claude-sib-ws-creator-claude", "claude-sib-ws-reviewer-claude" + _write_rows(mam_sandbox, [ + _row(name_a, "claude", ws, epoch, status="stopped", claude_session_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row(name_b, "claude", ws, epoch, status="running", claude_session_id_own=None), + ]) + + dry = _run_resume(mam_sandbox, ws, name_b, "claude", dry_run=True) + assert dry.returncode == 0, dry.stdout + dry.stderr + + st = _run_stop(mam_sandbox, name_b, "claude") + assert st.returncode == 0, st.stdout + st.stderr + assert _read_rows(mam_sandbox)[name_b]["claude_session_id_own"] == UB + + dry_after = _run_resume(mam_sandbox, ws, name_b, "claude", dry_run=True) + assert dry_after.returncode == 0, dry_after.stdout + dry_after.stderr + assert f"-r {UB}" in dry_after.stdout + + + -- 2.54.0 From e594d2b6503747b3b55241c46be5efd2b8dfb4d0 Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 15:55:37 +0000 Subject: [PATCH 05/14] feat(create,resume): same-cwd ordering of post-discovered agents (Issue #63) --- .agents/skills/lib.sh | 15 ++ .agents/skills/lib_py/agents/id_order.py | 249 ++++++++++++++++++ .../scripts/create_session.sh | 4 +- .../scripts/resume_session.sh | 4 + tests/test_issue63_same_cwd_ids.py | 174 ++++++++++++ 5 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/lib_py/agents/id_order.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 5aee8c4..67ff805 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2051,6 +2051,21 @@ sys.exit(main(sys.argv[1:])) PYEOF } +# --------------------------------------------------------------------------- +# mam_id_order_wait [uuid] +# Issue #63: same-cwd creation/resume ordering for post-discovered agents. +# --------------------------------------------------------------------------- +mam_id_order_wait() { + local workspace="$1" agent="$2" session_name="$3" uuid="${4:-}" + local abs; abs="$(mam_abs_workspace "$workspace")" + env_python "$AGENT_SESSIONS_YAML" wait --cwd "$abs" --agent "$agent" \ + --session "$session_name" --owner-pid "$$" --uuid "$uuid" <<'PYEOF' +import sys +from lib_py.agents.id_order import main +sys.exit(main(sys.argv[1:])) +PYEOF +} + # --------------------------------------------------------------------------- # Session isolation — Universal Global Config (Option B, P3-1) # diff --git a/.agents/skills/lib_py/agents/id_order.py b/.agents/skills/lib_py/agents/id_order.py new file mode 100644 index 0000000..32360d6 --- /dev/null +++ b/.agents/skills/lib_py/agents/id_order.py @@ -0,0 +1,249 @@ +"""Same-cwd creation/resume ordering for post-discovered agents (Issue #63). + +While a post-discovered agent (agy, codex, hermes, opencode) in a workspace is + still being created (marker held by a live create/resume process) or is running +without a recorded conversation id (live pane pid), a second create or resume of +the same agent type in that workspace waits until the first records its id, up +to MAM_ID_ORDER_WAIT_S (default 600 s; 0 = do not wait). +""" +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import os +import sys +import tempfile +import time +from typing import Any, Callable, Dict, List, Mapping, Optional + +from lib_py import ints +from lib_py.agents.autopin import claimed_elsewhere +from lib_py.agents.base import POST_DISCOVERED +from lib_py.agents.registry import agent_of_row, get_adapter +from lib_py.agents.session_ids import claiming_rows, own_id +from lib_py.state import load_state_json +from lib_py.verify_session import workspace_key + +DEFAULT_WAIT_S = 600 + + +def wait_s(env: Mapping[str, str] = os.environ) -> int: + raw = env.get("MAM_ID_ORDER_WAIT_S") + if raw is None: + return DEFAULT_WAIT_S + n = ints.positive_int(raw, 0) + return DEFAULT_WAIT_S if n is None else n + + +def pid_alive(pid: int) -> bool: + if not isinstance(pid, int) or pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + + +def order_dir(cwd: str, agent: str, env: Mapping[str, str] = os.environ, + home: Optional[str] = None) -> str: + h = home or env.get("HOME") or env.get("HOME_DIR") or os.path.expanduser("~") + real_cwd = os.path.realpath(cwd or ".") + digest = hashlib.sha256(real_cwd.encode("utf-8")).hexdigest()[:16] + return os.path.join(h, ".local", "state", "mam", "id-order", f"{digest}-{agent}") + + +def _parse_pos_int(val: Any) -> Optional[int]: + if isinstance(val, bool): + return None + if isinstance(val, int): + return val if val > 0 else None + s = str(val or "").strip() + return int(s) if s.isdigit() and int(s) > 0 else None + + +def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str, + markers: List[Dict[str, Any]], + is_alive: Callable[[int], bool] = pid_alive) -> List[str]: + target_key = workspace_key(cwd) + found: List[str] = [] + rows_by_name: Dict[str, Dict[str, Any]] = {} + + for s in state.get("herdr_sessions") or []: + if not isinstance(s, dict): + continue + rname = str(s.get("name") or "") + if rname: + rows_by_name[rname] = s + if not rname or rname == me: + continue + s_cwd = (s.get("pane") or {}).get("cwd") or "" + if not s_cwd or workspace_key(s_cwd) != target_key: + continue + if agent_of_row(s) != agent or s.get("status") != "running": + continue + if own_id(s, agent): + continue + pane_pid = _parse_pos_int((s.get("pane") or {}).get("pid")) + if pane_pid is None or is_alive(pane_pid): + if rname not in found: + found.append(rname) + + for m in markers or []: + if not isinstance(m, dict): + continue + m_sess = str(m.get("session") or "") + if not m_sess or m_sess == me or m_sess in found: + continue + owner_pid = _parse_pos_int(m.get("owner_pid")) + if owner_pid is None or not is_alive(owner_pid): + continue + row = rows_by_name.get(m_sess) + if row is None: + found.append(m_sess) + elif not own_id(row, agent): + status = row.get("status") + end_epoch = int(row.get("stopped_at_epoch") or row.get("terminated_at_epoch") or 0) + m_at = int(m.get("at") or 0) + if status == "running" or end_epoch <= m_at: + found.append(m_sess) + + return found + + +def _read_and_prune_markers(dpath: str, me: str, + is_alive: Callable[[int], bool] = pid_alive) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + try: + names = sorted(os.listdir(dpath)) + except OSError: + return out + for fname in names: + if not fname.endswith(".json") or fname.startswith("."): + continue + fpath = os.path.join(dpath, fname) + try: + with open(fpath, encoding="utf-8") as f: + data = json.load(f) + except (OSError, ValueError): + try: + os.remove(fpath) + except OSError: + pass + continue + if not isinstance(data, dict): + continue + owner_pid = _parse_pos_int(data.get("owner_pid")) + sess = str(data.get("session") or "") + if sess == me or owner_pid is None or not is_alive(owner_pid): + try: + os.remove(fpath) + except OSError: + pass + continue + out.append(data) + return out + + +def write_marker(dpath: str, session: str, owner_pid: int, cwd: str, agent: str) -> str: + os.makedirs(dpath, exist_ok=True) + marker_path = os.path.join(dpath, f"{session}.json") + fd, tmp = tempfile.mkstemp(dir=dpath, prefix=".marker.", suffix=".tmp") + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump({ + "session": session, + "owner_pid": int(owner_pid), + "cwd": os.path.realpath(cwd), + "agent": agent, + "at": int(time.time()), + }, f) + os.replace(tmp, marker_path) + return marker_path + + +def wait(cwd: str, agent: str, session: str, owner_pid: int, + uuid: str = "", yaml_path: Optional[str] = None, + home: Optional[str] = None, wait_s_val: Optional[int] = None, + poll: float = 0.25, + is_alive: Callable[[int], bool] = pid_alive) -> int: + adapter = get_adapter(agent) + if adapter is None or adapter.id_lifecycle != POST_DISCOVERED: + return 0 + + cap = wait_s(os.environ) if wait_s_val is None else int(wait_s_val) + dpath = order_dir(cwd, agent, os.environ, home=home) + os.makedirs(dpath, exist_ok=True) + lock_file = os.path.join(dpath, "lock") + deadline = time.monotonic() + cap + announced = False + row_ref = {"name": session} + + while True: + with open(lock_file, "a", encoding="utf-8") as lf: + fcntl.flock(lf, fcntl.LOCK_EX) + state = load_state_json(yaml_path) + if uuid: + claimed = set(claimed_elsewhere(row_ref, state)) + if uuid in claimed: + others = ", ".join(claiming_rows(row_ref, uuid, state)) + print( + f"ERROR: {session}'s recorded id {uuid} is also recorded by {others}; " + f"refusing to resume on a conversation another session claims (Issue #63).", + file=sys.stderr, flush=True, + ) + return 1 + markers = _read_and_prune_markers(dpath, session, is_alive=is_alive) + blks = blockers(state, cwd, agent, session, markers, is_alive=is_alive) + if not blks: + if not uuid and owner_pid and owner_pid > 0: + write_marker(dpath, session, owner_pid, cwd, agent) + return 0 + + names = ", ".join(blks) + if time.monotonic() >= deadline: + print( + f"ERROR: {names} in {cwd} still has no recorded {agent} id after {cap}s; " + f"not starting {session} (Issue #63). Wait for its first turn to finish, " + f"or stop it with multi-agent-mux-stop.", + file=sys.stderr, flush=True, + ) + return 1 + if not announced: + print( + f"id-order: waiting for {names} ({agent}, no recorded id yet) in {cwd} " + f"- up to {cap}s (MAM_ID_ORDER_WAIT_S)", + file=sys.stderr, flush=True, + ) + announced = True + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(poll, remaining)) + + +def main(argv: Optional[List[str]] = None) -> int: + p = argparse.ArgumentParser(prog="python -m lib_py.agents.id_order") + sub = p.add_subparsers(dest="cmd", required=True) + s_wait = sub.add_parser("wait") + s_wait.add_argument("--cwd", required=True) + s_wait.add_argument("--agent", required=True) + s_wait.add_argument("--session", required=True) + s_wait.add_argument("--owner-pid", type=int, default=0) + s_wait.add_argument("--uuid", default="") + a = p.parse_args(argv) + return wait( + cwd=a.cwd, + agent=a.agent, + session=a.session, + owner_pid=a.owner_pid, + uuid=a.uuid or "", + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 210e2ad..365598a 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -283,10 +283,10 @@ if [ "$DRY_RUN" = "1" ]; then echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT, herdr_session=${HERDR_SESSION_NAME:-default}, herdr_workspace=${MAM_WS_LABEL})" exit 0 fi - +# Issue #63: a same-cwd session of this post-discovered agent records its id first (lib.sh mam_id_order_wait). +mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" || exit 1 HERDR_EPOCH=$(date +%s) NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') - spawn # _mark_row_rolled_back diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 6988234..400e827 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -238,6 +238,10 @@ if [ "${DRY_RUN:-0}" = "1" ]; then exit 0 fi +# Issue #63: wait if another same-cwd session of this post-discovered agent +# is still being created or has not recorded its id yet (lib.sh mam_id_order_wait). +mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" "$UUID" || exit 1 + if [ "$AGENT" = "opencode" ]; then if [ -z "${OPENCODE_PERMISSION:-}" ]; then export OPENCODE_PERMISSION='{"*":"allow"}' diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index 1d6ece5..c6e0aa7 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -612,4 +612,178 @@ def test_rev2_claude_unpinned_sibling_row_skips_creator_claimed_id(mam_sandbox, assert f"-r {UB}" in dry_after.stdout +# --------------------------------------------------------------------------- Done Criterion 6 (id_order & create/resume wait) + +def test_c6_create_waits_or_refuses_while_same_cwd_post_discovered_row_has_no_id( + mam_sandbox, mock_herdr, mock_agents +): + """Done Criterion 6 (a) & (b): A is running without an id and has a live pane pid. + (a) With MAM_ID_ORDER_WAIT_S=0, create B exits 1 without spawning or adding a row + and names A on stderr. + (b) With MAM_ID_ORDER_WAIT_S=30, once A records its id in a background thread, + create B proceeds (rc 0) and records herdr_session_epoch >= the pin time.""" + import threading + + ws = mam_sandbox / "c6-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch_a = int(time.time()) - 100 + name_a, name_b = "c6-ws-creator-agy", "c6-ws-reviewer-agy" + create_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + + holder = subprocess.Popen(["sleep", "120"]) + try: + _write_rows(mam_sandbox, [_row(name_a, "agy", ws, epoch_a, pid=holder.pid)]) + _mock_herdr_agent(mock_herdr, name_a, ws, holder.pid, epoch_a, pane_id="w1:p_a") + + # (a) Wait cap 0 -> immediate refusal + env0 = dict(os.environ, MAM_ID_ORDER_WAIT_S="0") + res0 = subprocess.run( + ["bash", str(create_sh), "--workspace", str(ws), "--agent", "agy", + "--role", "reviewer", "--session", name_b, "--no-onboard"], + capture_output=True, text=True, cwd=str(mam_sandbox), env=env0, timeout=120, + ) + assert res0.returncode == 1, res0.stdout + res0.stderr + assert name_a in res0.stderr + assert name_b not in _read_rows(mam_sandbox) + herdr_state = json.loads(Path(mock_herdr).read_text()) + assert name_b not in herdr_state.get("agents", {}) + + # Also check resume --dry-run does not wait even with MAM_ID_ORDER_WAIT_S=0 + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch_a, pid=holder.pid), + _row(name_b, "agy", ws, epoch_a, status="stopped", agy_conversation_id_own=UB), + ]) + _agy_full_artifacts(mam_sandbox, UB) + dry_res = subprocess.run( + ["bash", str(mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"), + "--workspace", str(ws), "--agent", "agy", "--session", name_b, "--dry-run"], + capture_output=True, text=True, cwd=str(mam_sandbox), env=env0, timeout=60, + ) + assert dry_res.returncode == 0, dry_res.stdout + dry_res.stderr + + # Reset to only A running without id for (b) + _write_rows(mam_sandbox, [_row(name_a, "agy", ws, epoch_a, pid=holder.pid)]) + pinned_at = [] + + def _pin_a_later(): + time.sleep(1.5) + pinned_at.append(int(time.time())) + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch_a, pid=holder.pid, agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + ]) + + t = threading.Thread(target=_pin_a_later) + t.start() + try: + env30 = dict(os.environ, MAM_ID_ORDER_WAIT_S="30") + res_ok = subprocess.run( + ["bash", str(create_sh), "--workspace", str(ws), "--agent", "agy", + "--role", "reviewer", "--session", name_b, "--no-onboard"], + capture_output=True, text=True, cwd=str(mam_sandbox), env=env30, timeout=120, + ) + finally: + t.join(timeout=10) + + assert res_ok.returncode == 0, res_ok.stdout + res_ok.stderr + assert "id-order: waiting for" in res_ok.stderr and name_a in res_ok.stderr + rows = _read_rows(mam_sandbox) + assert name_b in rows and rows[name_b]["status"] == "running" + assert pinned_at and rows[name_b]["herdr_session_epoch"] >= pinned_at[0] + finally: + holder.kill() + holder.wait() + + +def test_c6_id_order_markers_non_blocking_cases_and_uuid_recheck(tmp_path, monkeypatch): + """Done Criterion 6 (c), (d), and Criterion 7 uuid recheck during wait + knob parsing.""" + from lib_py.agents import id_order + + home = tmp_path / "home" + ws = tmp_path / "ws" + ws_other = tmp_path / "ws-other" + ws.mkdir(parents=True) + ws_other.mkdir(parents=True) + yaml_path = tmp_path / "agent-sessions.yaml" + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": []})) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path)) + + # Knob parsing rules + assert id_order.wait_s({}) == 600 + assert id_order.wait_s({"MAM_ID_ORDER_WAIT_S": "0"}) == 0 + assert id_order.wait_s({"MAM_ID_ORDER_WAIT_S": " 05 "}) == 5 + for bad in ("abc", "-1", "9" * 10, ""): + assert id_order.wait_s({"MAM_ID_ORDER_WAIT_S": bad}) == 600, bad + + # (c) Marker-only "being created": blocks while owner_pid is alive, passes once dead + dpath = id_order.order_dir(str(ws), "agy", home=str(home)) + proc = subprocess.Popen(["sleep", "120"]) + try: + id_order.write_marker(dpath, "creating-a-agy", proc.pid, str(ws), "agy") + assert id_order.wait(str(ws), "agy", "creating-b-agy", os.getpid(), + yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 1 + finally: + proc.kill() + proc.wait() + + # Dead owner_pid marker is pruned and wait succeeds + assert id_order.wait(str(ws), "agy", "creating-b-agy", os.getpid(), + yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 0 + # Clean up creating-b-agy's own marker for next sub-checks + for f in Path(dpath).glob("*.json"): + f.unlink() + + # (d) Non-blocking cases: claude, different folder, different agent, dead pane pid, pinned row + dead_proc = subprocess.Popen(["true"]) + dead_proc.wait() + live_proc = subprocess.Popen(["sleep", "120"]) + try: + state_nonblock = { + "herdr_sessions": [ + _row("other-cwd-agy", "agy", ws_other, 100, pid=live_proc.pid), + _row("same-cwd-codex", "codex", ws, 100, pid=live_proc.pid), + _row("dead-pane-agy", "agy", ws, 100, pid=dead_proc.pid), + _row("pinned-agy", "agy", ws, 100, pid=live_proc.pid, agy_conversation_id_own=UA), + ] + } + yaml_path.write_text(yaml.safe_dump(state_nonblock)) + assert id_order.wait(str(ws), "claude", "new-claude", os.getpid(), + yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 0 + assert id_order.wait(str(ws), "agy", "new-agy", os.getpid(), + yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 0 + + # Criterion 7: wait --uuid refuses when another row already claimed that uuid + assert id_order.wait(str(ws), "agy", "resuming-agy", os.getpid(), uuid=UA, + yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 1 + finally: + live_proc.kill() + live_proc.wait() + + +def test_portability_python39_ast_and_bash_syntax(): + """Static guard: new/updated Python modules parse under Python 3.9 and shell scripts pass bash -n.""" + import ast + + for rel in ( + "lib_py/workspace_uuid.py", + "lib_py/agents/session_ids.py", + "lib_py/agents/id_order.py", + ): + src = (SKILLS / rel).read_text(encoding="utf-8") + ast.parse(src, filename=rel, feature_version=(3, 9)) + + for rel in ( + "lib.sh", + "multi-agent-mux-stop/scripts/stop_session.sh", + "multi-agent-mux-resume/scripts/resolve_session_id.sh", + "multi-agent-mux-resume/scripts/resume_session.sh", + "multi-agent-mux-resume/scripts/update_yaml_resumed.sh", + "multi-agent-mux-create/scripts/create_session.sh", + ): + res = subprocess.run(["bash", "-n", str(SKILLS / rel)], capture_output=True, text=True, timeout=30) + assert res.returncode == 0, f"{rel}: {res.stderr}" + + + -- 2.54.0 From 8c9a68cf1cdb5b77856fe8a0a531c599b7ebb527 Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 15:58:54 +0000 Subject: [PATCH 06/14] docs(stop,resume,create): the lines that became wrong (Issue #63) --- .agents/skills/multi-agent-mux-create/SKILL.md | 2 +- .agents/skills/multi-agent-mux-resume/SKILL.md | 18 +++++++++--------- .agents/skills/multi-agent-mux-stop/SKILL.md | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 9841f27..5a8c659 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -183,7 +183,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace | Exit | Meaning | |---|---| | 0 | Created; or `--dry-run` | -| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated. After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) | +| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) | | 2 | Bad arguments; control-plane binding refused for a worktree; whitespace in a control-plane path that must be forwarded | | 3 | A herdr session of that name already exists (`multi-agent-mux-resume`, or `multi-agent-mux-stop` first) | | 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back. A row the monitor registered for this very pane while it was being created is replaced instead | diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 44d9e3a..637d157 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -36,10 +36,10 @@ its row is `status: stopped` with `resumable: true` and the conversation id already recorded in `claude_session_id_own` / `agy_conversation_id_own`. This is the ideal resume path: -- **tier-1, race-free**: because the stop command wrote the id into the row at stop - time, `resolve_session_id.sh` resolves it via `find_workspace_uuid` tier-1 (the - per-row own id) — no reliance on the mtime-based disk scan, so a concurrent - session in another workspace can never shadow it. +- **tier-1, race-free**: when the row has its own id recorded, `session_ids.py` + resolves it directly from the per-row own id (after checking no other row + claims it and revalidating on disk) — no reliance on the mtime-based disk scan, + so a concurrent session in the same or another workspace can never shadow it. - On resume, `update_yaml_resumed.sh` transitions `stopped → running` and **clears the stop metadata** (`stopped_at`, `stopped_at_epoch`, `stop_reason`, `resumable`) along with the usual `terminated_at*` / `termination_mode` / `archived_at`, so the @@ -49,10 +49,10 @@ ideal resume path: `agent-sessions.yaml` and on-disk discovery are used to resolve the UUID in this order: -1. **`herdr_sessions[]` row's per-row own id** (`claude_session_id_own` / `agy_conversation_id_own` / `hermes_conversation_id_own` / `grok_session_id_own` / `opencode_session_id_own` / `codex_session_id_own`) — explicitly saved by `multi-agent-mux-stop` right before teardown (tier-1, race-free). -2. **Workspace-scoped on-disk scan** (adapter `discover()`) +1. **`herdr_sessions[]` row's per-row own id** (`claude_session_id_own` / `agy_conversation_id_own` / `hermes_conversation_id_own` / `grok_session_id_own` / `opencode_session_id_own` / `codex_session_id_own`) — verified against disk and refused (exit 1) if another row also records it (tier-1, race-free). +2. **Without an own id**: Class A (`agy`, `hermes`, `opencode`, `codex`) uses `#40` pane capture (`autopin.capture`) when herdr is alive, starts a fresh conversation (`FRESH_SPAWN=1` + `NOTE:` on stderr) when herdr is dead and 2+ same-type rows share the cwd, or scans the workspace when it is the only row; Class B (`claude`, `grok`) runs the workspace scan (`find_workspace_uuid`, skipping ids claimed by other rows). -If both are empty → for Class A agents (`agy`, `hermes`, `opencode`, `codex`), `resume_session.sh` automatically falls back to `FRESH_SPAWN=1` (`spawn-spec`). For Class B agents (`claude`, `grok`), fall back to `multi-agent-mux-create`. +If the resolved id is empty → for Class A agents (`agy`, `hermes`, `opencode`, `codex`), `resume_session.sh` uses `FRESH_SPAWN=1` (`spawn-spec`). For Class B agents (`claude`, `grok`), fall back to `multi-agent-mux-create`. ## Workflow @@ -75,11 +75,11 @@ bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace | Exit | Meaning | |---|---| | 0 | Resumed; or already running, so only the row was updated (no attach); or `--dry-run` | -| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), agent binary missing or not executable. **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) | +| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), or agent binary missing/not executable. **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) | | 2 | From the checks before spawn, nothing was changed: bad arguments, `--workspace` differs from the original cwd or it no longer exists, control-plane binding refused, whitespace in the control-plane path. **The YAML update after spawn also exits 2** (bad arguments) | | other | A later step failed (herdr spawn, start-up dialogs, YAML update) and its code is passed through. resume does not roll back | -A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-223). +A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-243). ## Original cwd only (Issue #21 phase 2) diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index c408342..baa1397 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -31,7 +31,7 @@ The stop command is always **graceful by default**: 1. Sends exit keys to the agent TUI (`/exit` for Claude, `/exit` for Agy) and waits 3 seconds. 2. If still alive, issues `herdr kill-session` (SIGTERM) and waits 5 seconds. 3. If still alive, kills the pane PID via SIGKILL (`kill -9`) as a last resort. -4. Auto-captures the conversation ID into the row (`claude_session_id_own`/`agy_conversation_id_own`) before killing, ensuring the next resume uses a race-free tier-1 lookup. +4. Auto-captures the conversation ID into the row (`claude_session_id_own`/`agy_conversation_id_own`) before killing (for Class A agents, only an ID verified as this pane's via `#40` capture is recorded, and nothing is recorded if the herdr session was already dead), ensuring the next resume uses a race-free tier-1 lookup. ## Pre-flight @@ -93,7 +93,7 @@ The script: 2. If `delegate_job_id` is set, automatically publishes a `progress --detail "terminating"` event to the multi-agent-mux-delegate-job registry 3. Captures the `last_visible_status` from `herdr capture-pane` (so we have a final TUI snapshot for audit) 4. Attempts graceful exit keys → SIGTERM kill-session → SIGKILL fallback -5. For `purge-conversation`: deletes, for claude, every path the session owns — `projects//.jsonl` and `/`, `file-history//`, `session-env//`, `jobs//` (when its `state.json` names the session), stale `sessions/.json` (+ that pid's `.key`) and `/claude-/*//`, then prints anything still carrying the UUID as `WARN residual:`; shared files such as `history.jsonl` are not rewritten (Issue #31). Or, for agy, `~/.gemini/antigravity-cli/conversations/.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain//`, `annotations/.pbtxt` and `presence/.lock` (the lock only once no live process references the conversation — Issue #10) +5. For `purge-conversation` (purging only the row's own ID or an ID verified for this pane, and never an ID another row records — Issue #63): deletes, for claude, every path the session owns — `projects//.jsonl` and `/`, `file-history//`, `session-env//`, `jobs//` (when its `state.json` names the session), stale `sessions/.json` (+ that pid's `.key`) and `/claude-/*//`, then prints anything still carrying the UUID as `WARN residual:`; shared files such as `history.jsonl` are not rewritten (Issue #31). Or, for agy, `~/.gemini/antigravity-cli/conversations/.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain//`, `annotations/.pbtxt` and `presence/.lock` (the lock only once no live process references the conversation — Issue #10) 6. Updates the YAML entry and SQLite database atomically 7. If `delegate_job_id` is set, publishes a `completed` event to the multi-agent-mux-delegate-job registry -- 2.54.0 From c747374aa6b685b6d42e62fed2c96a16e298ef90 Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 18:24:14 +0000 Subject: [PATCH 07/14] fix(session_ids,id_order): optional --session resolution, fresh-marker retention, and canonical cwd matching (Issue #63) --- .agents/skills/lib_py/agents/id_order.py | 75 ++++++-- .agents/skills/lib_py/agents/session_ids.py | 30 ++- tests/test_issue63_same_cwd_ids.py | 191 ++++++++++++++++++++ 3 files changed, 273 insertions(+), 23 deletions(-) diff --git a/.agents/skills/lib_py/agents/id_order.py b/.agents/skills/lib_py/agents/id_order.py index 32360d6..97f5074 100644 --- a/.agents/skills/lib_py/agents/id_order.py +++ b/.agents/skills/lib_py/agents/id_order.py @@ -22,9 +22,8 @@ from lib_py import ints from lib_py.agents.autopin import claimed_elsewhere from lib_py.agents.base import POST_DISCOVERED from lib_py.agents.registry import agent_of_row, get_adapter -from lib_py.agents.session_ids import claiming_rows, own_id +from lib_py.agents.session_ids import canonical_cwd, claiming_rows, own_id from lib_py.state import load_state_json -from lib_py.verify_session import workspace_key DEFAULT_WAIT_S = 600 @@ -54,7 +53,7 @@ def pid_alive(pid: int) -> bool: def order_dir(cwd: str, agent: str, env: Mapping[str, str] = os.environ, home: Optional[str] = None) -> str: h = home or env.get("HOME") or env.get("HOME_DIR") or os.path.expanduser("~") - real_cwd = os.path.realpath(cwd or ".") + real_cwd = canonical_cwd(cwd or ".") digest = hashlib.sha256(real_cwd.encode("utf-8")).hexdigest()[:16] return os.path.join(h, ".local", "state", "mam", "id-order", f"{digest}-{agent}") @@ -71,7 +70,7 @@ def _parse_pos_int(val: Any) -> Optional[int]: def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str, markers: List[Dict[str, Any]], is_alive: Callable[[int], bool] = pid_alive) -> List[str]: - target_key = workspace_key(cwd) + target_cwd = canonical_cwd(cwd) found: List[str] = [] rows_by_name: Dict[str, Dict[str, Any]] = {} @@ -84,7 +83,7 @@ def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str, if not rname or rname == me: continue s_cwd = (s.get("pane") or {}).get("cwd") or "" - if not s_cwd or workspace_key(s_cwd) != target_key: + if not s_cwd or canonical_cwd(s_cwd) != target_cwd: continue if agent_of_row(s) != agent or s.get("status") != "running": continue @@ -101,17 +100,40 @@ def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str, m_sess = str(m.get("session") or "") if not m_sess or m_sess == me or m_sess in found: continue + m_cwd = str(m.get("cwd") or "") + if m_cwd and canonical_cwd(m_cwd) != target_cwd: + continue + m_agent = str(m.get("agent") or "") + if m_agent and m_agent != agent: + continue owner_pid = _parse_pos_int(m.get("owner_pid")) if owner_pid is None or not is_alive(owner_pid): continue row = rows_by_name.get(m_sess) if row is None: found.append(m_sess) - elif not own_id(row, agent): - status = row.get("status") - end_epoch = int(row.get("stopped_at_epoch") or row.get("terminated_at_epoch") or 0) - m_at = int(m.get("at") or 0) - if status == "running" or end_epoch <= m_at: + continue + status = str(row.get("status") or "") + cur_own = own_id(row, agent) + row_epoch = int(row.get("herdr_session_epoch") or 0) + end_epoch = int(row.get("stopped_at_epoch") or row.get("terminated_at_epoch") or 0) + m_at = int(m.get("at") or 0) + prev_own = str(m.get("prev_own_id") or "") + prev_epoch = int(m.get("prev_epoch") or 0) + prev_end_epoch = int(m.get("prev_end_epoch") or 0) + + if status == "running": + pinned_for_attempt = bool(cur_own) and ( + cur_own != prev_own or row_epoch > prev_epoch + ) + if not pinned_for_attempt: + found.append(m_sess) + else: + ended_after_attempt = ( + end_epoch > max(m_at, prev_end_epoch) + and (prev_epoch == 0 or row_epoch > prev_epoch) + ) + if not ended_after_attempt: found.append(m_sess) return found @@ -151,18 +173,26 @@ def _read_and_prune_markers(dpath: str, me: str, return out -def write_marker(dpath: str, session: str, owner_pid: int, cwd: str, agent: str) -> str: +def write_marker(dpath: str, session: str, owner_pid: int, cwd: str, agent: str, + prev_row: Optional[Dict[str, Any]] = None) -> str: os.makedirs(dpath, exist_ok=True) marker_path = os.path.join(dpath, f"{session}.json") + payload: Dict[str, Any] = { + "session": session, + "owner_pid": int(owner_pid), + "cwd": canonical_cwd(cwd), + "agent": agent, + "at": int(time.time()), + } + if isinstance(prev_row, dict): + payload["prev_own_id"] = own_id(prev_row, agent) + payload["prev_epoch"] = int(prev_row.get("herdr_session_epoch") or 0) + payload["prev_end_epoch"] = int( + prev_row.get("stopped_at_epoch") or prev_row.get("terminated_at_epoch") or 0 + ) fd, tmp = tempfile.mkstemp(dir=dpath, prefix=".marker.", suffix=".tmp") with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump({ - "session": session, - "owner_pid": int(owner_pid), - "cwd": os.path.realpath(cwd), - "agent": agent, - "at": int(time.time()), - }, f) + json.dump(payload, f) os.replace(tmp, marker_path) return marker_path @@ -202,7 +232,14 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, blks = blockers(state, cwd, agent, session, markers, is_alive=is_alive) if not blks: if not uuid and owner_pid and owner_pid > 0: - write_marker(dpath, session, owner_pid, cwd, agent) + prev_row = next( + ( + s for s in (state.get("herdr_sessions") or []) + if isinstance(s, dict) and s.get("name") == session + ), + None, + ) + write_marker(dpath, session, owner_pid, cwd, agent, prev_row=prev_row) return 0 names = ", ".join(blks) diff --git a/.agents/skills/lib_py/agents/session_ids.py b/.agents/skills/lib_py/agents/session_ids.py index 44e5335..3d1b33e 100644 --- a/.agents/skills/lib_py/agents/session_ids.py +++ b/.agents/skills/lib_py/agents/session_ids.py @@ -21,10 +21,19 @@ 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, workspace_key +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): @@ -51,13 +60,13 @@ def claiming_rows(row: Dict[str, Any], uuid: str, state: Dict[str, Any]) -> List def same_type_rows(state: Dict[str, Any], cwd: str, agent: str) -> List[Dict[str, Any]]: if not cwd or not agent: return [] - target_key = workspace_key(cwd) + 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 workspace_key(s_cwd) == target_key and agent_of_row(s) == agent: + if s_cwd and canonical_cwd(s_cwd) == target_cwd and agent_of_row(s) == agent: out.append(s) return out @@ -195,9 +204,22 @@ def decide_resume(state: Dict[str, Any], name: str, agent: str, workspace: 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) + 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)) - adapter = get_adapter(agent) own = own_id(row, agent) if own: diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index c6e0aa7..d7fdd42 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -785,5 +785,196 @@ def test_portability_python39_ast_and_bash_syntax(): assert res.returncode == 0, f"{rel}: {res.stderr}" +def test_resolve_session_id_without_session_arg_returns_workspace_id(mam_sandbox): + """resolve_session_id.sh and decide_resume with name='' return the workspace's stopped row id.""" + from lib_py.agents.session_ids import decide_resume + + ws = mam_sandbox / "opt-sess-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + _agy_full_artifacts(mam_sandbox, UA) + _agy_cache(mam_sandbox, {ws: UA}) + _claude_transcript(mam_sandbox / ".claude" / "projects", ws, UB) + + rows = [ + _row("opt-sess-ws-creator-agy", "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA), + _row("opt-sess-ws-creator-claude", "claude", ws, epoch, status="stopped", claude_session_id_own=UB), + ] + _write_rows(mam_sandbox, rows) + state = {"herdr_sessions": rows} + + assert decide_resume(state, "", "agy", str(ws), home=str(mam_sandbox))["uuid"] == UA + assert decide_resume( + state, "", "claude", str(ws), home=str(mam_sandbox), + claude_dir=str(mam_sandbox / ".claude" / "projects"), + )["uuid"] == UB + + script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh" + res_agy = subprocess.run( + ["bash", str(script), "--workspace", str(ws), "--agent", "agy"], + cwd=str(mam_sandbox), capture_output=True, text=True, timeout=30, + ) + assert res_agy.returncode == 0 + assert res_agy.stdout.strip() == UA + + res_claude = subprocess.run( + ["bash", str(script), "--workspace", str(ws), "--agent", "claude"], + cwd=str(mam_sandbox), capture_output=True, text=True, timeout=30, + ) + assert res_claude.returncode == 0 + assert res_claude.stdout.strip() == UB +def test_id_order_old_stopped_row_with_own_id_does_not_release_fresh_startup_marker(tmp_path, monkeypatch): + """A pre-admission stopped row's own_id must not release a live startup marker + for a fresh resume (stale own_id) or a create reusing an old stopped session name.""" + import threading + from lib_py.agents import id_order + from lib_py.agents.session_ids import decide_resume + + home = tmp_path / "home" + ws = tmp_path / "ws" + ws.mkdir(parents=True) + yaml_path = tmp_path / "agent-sessions.yaml" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path)) + + for mode, old_own in (("stale_resume", UX), ("recreate_stopped", UA)): + if mode == "recreate_stopped": + _agy_conv(home, UA, time.time() - 50) + old_epoch = int(time.time()) - 100 + old_stop = int(time.time()) - 10 + row_a = _row( + "same-ws-creator-agy", "agy", ws, old_epoch, status="stopped", + agy_conversation_id_own=old_own, stopped_at_epoch=old_stop, + ) + state = {"herdr_sessions": [row_a]} + yaml_path.write_text(yaml.safe_dump(state)) + + if mode == "stale_resume": + dec = decide_resume(state, "same-ws-creator-agy", "agy", str(ws), home=str(home)) + assert dec["uuid"] == "" and dec["drop_stale_id"] is True + + proc_a = subprocess.Popen(["sleep", "120"]) + proc_b = subprocess.Popen(["sleep", "120"]) + try: + # Admit A (fresh startup, uuid=''): writes live marker while old stopped row is in state + assert id_order.wait( + str(ws), "agy", "same-ws-creator-agy", proc_a.pid, uuid="", + yaml_path=str(yaml_path), home=str(home), wait_s_val=0, + ) == 0 + + # Immediate check at cap 0: B must be blocked even before A updates its YAML row + assert id_order.wait( + str(ws), "agy", "same-ws-reviewer-agy", proc_b.pid, uuid="", + yaml_path=str(yaml_path), home=str(home), wait_s_val=0, + ) == 1 + + # Concurrent check: B waits while A transitions old-stopped -> running-unpinned -> running-pinned + b_rc: list = [] + + def _run_b(): + rc = id_order.wait( + str(ws), "agy", "same-ws-reviewer-agy", proc_b.pid, uuid="", + yaml_path=str(yaml_path), home=str(home), wait_s_val=10, poll=0.1, + ) + b_rc.append(rc) + + t = threading.Thread(target=_run_b) + t.start() + time.sleep(0.35) + assert b_rc == [], f"B unblocked prematurely while A was still old stopped row ({mode})" + + # A registers its fresh running row without an own_id + running_unpinned = _row( + "same-ws-creator-agy", "agy", ws, old_epoch + 100, + pid=proc_a.pid, status="running", agy_conversation_id_own=None, + ) + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [running_unpinned]})) + time.sleep(0.35) + assert b_rc == [], f"B unblocked prematurely while A was running unpinned ({mode})" + + # A records its new conversation id UB -> B unblocks + running_pinned = _row( + "same-ws-creator-agy", "agy", ws, old_epoch + 100, + pid=proc_a.pid, status="running", agy_conversation_id_own=UB, + ) + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [running_pinned]})) + t.join(timeout=5) + assert b_rc == [0], f"B did not unblock after A recorded its new id ({mode})" + finally: + proc_a.kill() + proc_b.kill() + proc_a.wait() + proc_b.wait() + + +def test_canonical_cwd_distinguishes_underscore_hyphen_and_nested_paths_with_symlink_control(tmp_path, monkeypatch): + """Distinct directories whose lossy workspace_key collides (project_a vs project-a, + nested/sub vs nested-sub) do not block or merge rows; a symlink alias to the same + directory does match.""" + from lib_py.agents import id_order + from lib_py.agents.session_ids import decide_resume, same_type_rows + + home = tmp_path / "home" + ws_under = tmp_path / "project_a" + ws_hyphen = tmp_path / "project-a" + ws_nested = tmp_path / "nested" / "sub" + ws_flat = tmp_path / "nested-sub" + for p in (ws_under, ws_hyphen, ws_nested, ws_flat): + p.mkdir(parents=True) + ws_alias = tmp_path / "project_a_link" + ws_alias.symlink_to(ws_under) + + yaml_path = tmp_path / "agent-sessions.yaml" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path)) + + _agy_conv(home, UA, time.time() - 10) + _agy_conv(home, UB, time.time() - 5) + _agy_cache(home, {ws_under: UA, ws_hyphen: UB}) + + proc = subprocess.Popen(["sleep", "120"]) + try: + state_live = { + "herdr_sessions": [ + _row("under-agy", "agy", ws_under, 100, pid=proc.pid, status="running"), + _row("nested-agy", "agy", ws_nested, 100, pid=proc.pid, status="running"), + ] + } + yaml_path.write_text(yaml.safe_dump(state_live)) + + # Colliding workspace_key directories do NOT block each other + assert id_order.wait( + str(ws_hyphen), "agy", "hyphen-agy", os.getpid(), + yaml_path=str(yaml_path), home=str(home), wait_s_val=0, + ) == 0 + assert id_order.wait( + str(ws_flat), "agy", "flat-agy", os.getpid(), + yaml_path=str(yaml_path), home=str(home), wait_s_val=0, + ) == 0 + # Symlink alias to ws_under DOES see live unpinned under-agy and blocks + assert id_order.wait( + str(ws_alias), "agy", "alias-agy", os.getpid(), + yaml_path=str(yaml_path), home=str(home), wait_s_val=0, + ) == 1 + finally: + proc.kill() + proc.wait() + + state_stopped = { + "herdr_sessions": [ + _row("under-agy", "agy", ws_under, 100, status="stopped"), + _row("hyphen-agy", "agy", ws_hyphen, 100, status="stopped"), + _row("nested-agy", "agy", ws_nested, 100, status="stopped"), + _row("flat-agy", "agy", ws_flat, 100, status="stopped"), + ] + } + assert [r["name"] for r in same_type_rows(state_stopped, str(ws_hyphen), "agy")] == ["hyphen-agy"] + assert [r["name"] for r in same_type_rows(state_stopped, str(ws_flat), "agy")] == ["flat-agy"] + assert [r["name"] for r in same_type_rows(state_stopped, str(ws_alias), "agy")] == ["under-agy"] + + # Single stopped row in ws_hyphen uses single-row workspace discovery (UB) instead of 2-row fresh spawn + res_hyphen = decide_resume(state_stopped, "hyphen-agy", "agy", str(ws_hyphen), home=str(home)) + assert res_hyphen["uuid"] == UB + assert res_hyphen["notes"] == [] -- 2.54.0 From d115bf71560a527354dc042eb9e7848f787c8637 Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 19:48:44 +0000 Subject: [PATCH 08/14] fix(id_order): reserve startup when resume resolves an unrecorded uuid (Issue #63) --- .agents/skills/lib_py/agents/id_order.py | 29 ++++++------ tests/test_issue63_same_cwd_ids.py | 56 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/.agents/skills/lib_py/agents/id_order.py b/.agents/skills/lib_py/agents/id_order.py index 97f5074..d31fc92 100644 --- a/.agents/skills/lib_py/agents/id_order.py +++ b/.agents/skills/lib_py/agents/id_order.py @@ -122,12 +122,14 @@ def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str, prev_epoch = int(m.get("prev_epoch") or 0) prev_end_epoch = int(m.get("prev_end_epoch") or 0) + pinned_for_attempt = bool(cur_own) and ( + cur_own != prev_own + or (status == "running" and row_epoch > prev_epoch) + ) + if pinned_for_attempt: + continue if status == "running": - pinned_for_attempt = bool(cur_own) and ( - cur_own != prev_own or row_epoch > prev_epoch - ) - if not pinned_for_attempt: - found.append(m_sess) + found.append(m_sess) else: ended_after_attempt = ( end_epoch > max(m_at, prev_end_epoch) @@ -231,14 +233,15 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, markers = _read_and_prune_markers(dpath, session, is_alive=is_alive) blks = blockers(state, cwd, agent, session, markers, is_alive=is_alive) if not blks: - if not uuid and owner_pid and owner_pid > 0: - prev_row = next( - ( - s for s in (state.get("herdr_sessions") or []) - if isinstance(s, dict) and s.get("name") == session - ), - None, - ) + prev_row = next( + ( + s for s in (state.get("herdr_sessions") or []) + if isinstance(s, dict) and s.get("name") == session + ), + None, + ) + already_recorded = bool(uuid) and own_id(prev_row, agent) == uuid + if not already_recorded and owner_pid and owner_pid > 0: write_marker(dpath, session, owner_pid, cwd, agent, prev_row=prev_row) return 0 diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index d7fdd42..0e95446 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -978,3 +978,59 @@ def test_canonical_cwd_distinguishes_underscore_hyphen_and_nested_paths_with_sym res_hyphen = decide_resume(state_stopped, "hyphen-agy", "agy", str(ws_hyphen), home=str(home)) assert res_hyphen["uuid"] == UB assert res_hyphen["notes"] == [] + + +def test_id_order_resolved_but_unrecorded_resume_uuid_reserves_startup_until_recorded(tmp_path, monkeypatch): + """When a single stopped post-discovered row with no own_id resolves a uuid from disk, + id_order.wait reserves startup (writes a marker) until that uuid is recorded on the row.""" + from lib_py.agents import id_order + from lib_py.agents.session_ids import decide_resume + + home = tmp_path / "home" + ws = tmp_path / "ws" + ws.mkdir(parents=True) + yaml_path = tmp_path / "agent-sessions.yaml" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path)) + + _agy_conv(home, UA, time.time() - 10) + _agy_cache(home, {ws: UA}) + + old_epoch = int(time.time()) - 100 + row_a = _row("same-ws-creator-agy", "agy", ws, old_epoch, status="stopped", agy_conversation_id_own=None) + state = {"herdr_sessions": [row_a]} + yaml_path.write_text(yaml.safe_dump(state)) + + dec = decide_resume(state, "same-ws-creator-agy", "agy", str(ws), home=str(home)) + assert dec["uuid"] == UA + + proc_a = subprocess.Popen(["sleep", "120"]) + proc_b = subprocess.Popen(["sleep", "120"]) + try: + # Admit A with resolved (but not yet recorded) uuid UA -> writes startup marker + assert id_order.wait( + str(ws), "agy", "same-ws-creator-agy", proc_a.pid, uuid=UA, + yaml_path=str(yaml_path), home=str(home), wait_s_val=0, + ) == 0 + + # While A has not yet recorded UA on its row, B must be refused at cap 0 + assert id_order.wait( + str(ws), "agy", "same-ws-reviewer-agy", proc_b.pid, uuid="", + yaml_path=str(yaml_path), home=str(home), wait_s_val=0, + ) == 1 + + # Once A records UA on its row (via update_yaml_resumed.sh), B is released + recorded_a = _row( + "same-ws-creator-agy", "agy", ws, old_epoch + 50, + pid=proc_a.pid, status="running", agy_conversation_id_own=UA, + ) + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [recorded_a]})) + assert id_order.wait( + str(ws), "agy", "same-ws-reviewer-agy", proc_b.pid, uuid="", + yaml_path=str(yaml_path), home=str(home), wait_s_val=0, + ) == 0 + finally: + proc_a.kill() + proc_b.kill() + proc_a.wait() + proc_b.wait() -- 2.54.0 From a907cc529418bc9c8a2e72859fb484dca14ab579 Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 23:53:10 +0000 Subject: [PATCH 09/14] chore(stop,resume): drop what Issue #63 left unused (Issue #63) - workspace_uuid.py: the unused sys import. - stop_session.sh: the orphan CAPTURE_ID and the blank padding that kept later line numbers; the inventory pins follow the move instead. - lib.sh: capture_conversation_id, which nothing calls any more. - resume SKILL.md: the flow names the session-id decision (session_ids.py), not resolve_session_id.sh. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 16 ---------------- .agents/skills/lib_py/workspace_uuid.py | 2 +- .agents/skills/multi-agent-mux-resume/SKILL.md | 2 +- .../multi-agent-mux-stop/scripts/stop_session.sh | 5 ----- tests/data/skill_code_blocks.json | 10 +++++----- 5 files changed, 7 insertions(+), 28 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 67ff805..06d9a7b 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2023,22 +2023,6 @@ find_workspace_uuid_main() PYEOF } -# --------------------------------------------------------------------------- -# capture_conversation_id [session_name] -# -# Thin wrapper over find_workspace_uuid: resolves THIS workspace's conversation -# id (claude jsonl sessionId / agy db uuid) and prints it on stdout (empty line -# if none). find_workspace_uuid is already a workspace-scoped, 2-tier, race-free -# resolver (per-row own id -> workspace-scoped disk scan), -# so recording its result into the row before kill guarantees tier-1 on the next -# resume. Pass session_name to prefer that specific row's recorded id. Always exits 0. -# Note (Issue #63): stop_session.sh no longer calls this directly (it uses mam_session_ids). -# --------------------------------------------------------------------------- -capture_conversation_id() { - local agent="$1" workdir="$2" session_name="${3:-}" - find_workspace_uuid "$workdir" "$agent" "$session_name" -} - # --------------------------------------------------------------------------- # mam_session_ids [args...] # Issue #63: stop/purge/resume conversation-id decision (lib_py.agents.session_ids). diff --git a/.agents/skills/lib_py/workspace_uuid.py b/.agents/skills/lib_py/workspace_uuid.py index b5665d5..26bdc30 100644 --- a/.agents/skills/lib_py/workspace_uuid.py +++ b/.agents/skills/lib_py/workspace_uuid.py @@ -1,7 +1,7 @@ # workspace_uuid.py — workspace UUID discovery logic # Extracted from lib.sh find_workspace_uuid PYEOF block -import os, sys, json +import os, json from lib_py.verify_session import verify_session_uuid, mam_orchestrator_uuids, mam_row_own_uuid OWN_KEY = { diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 637d157..8707f99 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -60,7 +60,7 @@ If the resolved id is empty → for Class A agents (`agy`, `hermes`, `opencode`, bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace --agent --session ``` -`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, session-id resolution (`resolve_session_id.sh`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: `herdr --session agent attach ` (`` = the `--herdr-session` you passed, or the row's `herdr_session`). +`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: `herdr --session agent attach ` (`` = the `--herdr-session` you passed, or the row's `herdr_session`). | Option | Meaning | |---|---| diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 67aec7a..c46583d 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -67,7 +67,6 @@ HERDR_SERVER_OPT="" HERDR_WORKSPACE_OPT="" PURGE=0 YES=0 -CAPTURE_ID=1 GRACEFUL=1 REASON="manual_stop" STOP_MODE=1 @@ -224,10 +223,6 @@ if [ -n "$TARGET_CWD" ]; then else echo "WARN: no conversation id recorded before stop${STOP_ID_NOTE:+ - $STOP_ID_NOTE}"; fi fi - - -# (end of pre-kill id decision) - delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" # graceful 종료: send-keys 로 정상 종료 유도 → 폴백 체인 (SIGTERM → SIGKILL). diff --git a/tests/data/skill_code_blocks.json b/tests/data/skill_code_blocks.json index 10cb1c3..9a4bd68 100644 --- a/tests/data/skill_code_blocks.json +++ b/tests/data/skill_code_blocks.json @@ -433,7 +433,7 @@ "class": "c", "kind": "diagram", "evidence": [ - "multi-agent-mux-stop/scripts/stop_session.sh:184-198" + "multi-agent-mux-stop/scripts/stop_session.sh:183-197" ], "mismatch": null, "note": "state machine" @@ -503,9 +503,9 @@ "base_sha": "a7ed3e902cae", "class": "a", "evidence": [ - "multi-agent-mux-stop/scripts/stop_session.sh:99", - "multi-agent-mux-stop/scripts/stop_session.sh:140-143", - "multi-agent-mux-stop/scripts/stop_session.sh:184-188" + "multi-agent-mux-stop/scripts/stop_session.sh:98", + "multi-agent-mux-stop/scripts/stop_session.sh:139-142", + "multi-agent-mux-stop/scripts/stop_session.sh:183-187" ], "mismatch": [ "T1: the doc offered --force-no-yaml, which no script accepts" @@ -599,7 +599,7 @@ "other" ], "passthrough": { - "1": "multi-agent-mux-stop/scripts/stop_session.sh:327" + "1": "multi-agent-mux-stop/scripts/stop_session.sh:322" } }, "multi-agent-mux-create/scripts/create_session.sh": { -- 2.54.0 From ede9c62be39a3c92662e7a0cd90c999c49c1d38f Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 23 Oct 2026 07:39:43 +0900 Subject: [PATCH 10/14] fix(id_order): keep an orphaned marker blocking while its herdr session is alive (Issue #63) A create/resume killed after spawning its pane but before registering its row left a marker whose owner pid was dead; the next same-cwd waiter pruned it on the pid alone and could start a second session that discovers the same workspace conversation id (PR #69 review P1). - Markers record the herdr server. A dead owner's marker is pruned only once its row is registered for the attempt (running, newer epoch: the row decides) or `_herdr has-session` reports the session gone; an unanswered probe counts as alive. On timeout the kill-session command is printed. - A live marker of another process for the waiter's own session name is kept and blocks instead of being deleted; the waiter's own pid's markers go. - Tests: orphan / registered / gone / same-session / missing pane pid cases, and a create-level reproduction against mock herdr (fails before the fix). Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 3 +- .agents/skills/lib_py/agents/id_order.py | 86 ++++++++++++-- tests/test_issue63_same_cwd_ids.py | 141 ++++++++++++++++++++++- 3 files changed, 216 insertions(+), 14 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 06d9a7b..d107fbe 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2043,7 +2043,8 @@ mam_id_order_wait() { local workspace="$1" agent="$2" session_name="$3" uuid="${4:-}" local abs; abs="$(mam_abs_workspace "$workspace")" env_python "$AGENT_SESSIONS_YAML" wait --cwd "$abs" --agent "$agent" \ - --session "$session_name" --owner-pid "$$" --uuid "$uuid" <<'PYEOF' + --session "$session_name" --owner-pid "$$" --uuid "$uuid" \ + --herdr-server "${HERDR_SESSION_NAME:-}" <<'PYEOF' import sys from lib_py.agents.id_order import main sys.exit(main(sys.argv[1:])) diff --git a/.agents/skills/lib_py/agents/id_order.py b/.agents/skills/lib_py/agents/id_order.py index d31fc92..37ca3c2 100644 --- a/.agents/skills/lib_py/agents/id_order.py +++ b/.agents/skills/lib_py/agents/id_order.py @@ -4,7 +4,9 @@ While a post-discovered agent (agy, codex, hermes, opencode) in a workspace is still being created (marker held by a live create/resume process) or is running without a recorded conversation id (live pane pid), a second create or resume of the same agent type in that workspace waits until the first records its id, up -to MAM_ID_ORDER_WAIT_S (default 600 s; 0 = do not wait). +to MAM_ID_ORDER_WAIT_S (default 600 s; 0 = do not wait). A marker whose owner +died before the row was registered keeps blocking while its herdr session is +alive (the pane may still discover the workspace id). """ from __future__ import annotations @@ -13,6 +15,7 @@ import fcntl import hashlib import json import os +import subprocess import sys import tempfile import time @@ -50,6 +53,24 @@ def pid_alive(pid: int) -> bool: return False +def herdr_session_alive(server: str, session: str) -> bool: + """lib.sh `_herdr has-session` on the marker's herdr server; an unanswered + probe counts as alive (the wait stays bounded by MAM_ID_ORDER_WAIT_S).""" + skills_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + env = dict(os.environ) + if server: + env["HERDR_SESSION_NAME"] = server + script = 'source "$1" && _herdr has-session -t "$2"' + try: + return subprocess.run( + ["bash", "-c", script, "_", os.path.join(skills_dir, "lib.sh"), session], + env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=30, + ).returncode == 0 + except (OSError, subprocess.SubprocessError): + return True + + def order_dir(cwd: str, agent: str, env: Mapping[str, str] = os.environ, home: Optional[str] = None) -> str: h = home or env.get("HOME") or env.get("HOME_DIR") or os.path.expanduser("~") @@ -98,7 +119,7 @@ def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str, if not isinstance(m, dict): continue m_sess = str(m.get("session") or "") - if not m_sess or m_sess == me or m_sess in found: + if not m_sess or m_sess in found: continue m_cwd = str(m.get("cwd") or "") if m_cwd and canonical_cwd(m_cwd) != target_cwd: @@ -106,9 +127,6 @@ def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str, m_agent = str(m.get("agent") or "") if m_agent and m_agent != agent: continue - owner_pid = _parse_pos_int(m.get("owner_pid")) - if owner_pid is None or not is_alive(owner_pid): - continue row = rows_by_name.get(m_sess) if row is None: found.append(m_sess) @@ -141,8 +159,24 @@ def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str, return found -def _read_and_prune_markers(dpath: str, me: str, - is_alive: Callable[[int], bool] = pid_alive) -> List[Dict[str, Any]]: +def _registered_for_attempt(m: Dict[str, Any], row: Optional[Dict[str, Any]]) -> bool: + if not isinstance(row, dict) or row.get("status") != "running": + return False + return int(row.get("herdr_session_epoch") or 0) > int(m.get("prev_epoch") or 0) + + +def _read_and_prune_markers(dpath: str, me: str, my_pid: int, state: Dict[str, Any], + is_alive: Callable[[int], bool] = pid_alive, + pane_alive: Callable[[str, str], bool] = herdr_session_alive, + ) -> List[Dict[str, Any]]: + """Markers that still count. A live owner keeps its marker (another live + attempt on `me` included). A dead owner's marker goes once its row is + registered for the attempt (the row decides from then on) or its herdr + session is gone; until then it is kept as an orphan.""" + rows_by_name = { + str(s.get("name") or ""): s + for s in (state.get("herdr_sessions") or []) if isinstance(s, dict) + } out: List[Dict[str, Any]] = [] try: names = sorted(os.listdir(dpath)) @@ -165,7 +199,18 @@ def _read_and_prune_markers(dpath: str, me: str, continue owner_pid = _parse_pos_int(data.get("owner_pid")) sess = str(data.get("session") or "") - if sess == me or owner_pid is None or not is_alive(owner_pid): + if owner_pid is None or owner_pid == my_pid: + drop = True + elif is_alive(owner_pid): + drop = False + elif sess == me or not sess: + drop = True + elif _registered_for_attempt(data, rows_by_name.get(sess)): + drop = True + else: + drop = not pane_alive(str(data.get("herdr_server") or ""), sess) + data["_orphan"] = True + if drop: try: os.remove(fpath) except OSError: @@ -176,7 +221,7 @@ def _read_and_prune_markers(dpath: str, me: str, def write_marker(dpath: str, session: str, owner_pid: int, cwd: str, agent: str, - prev_row: Optional[Dict[str, Any]] = None) -> str: + prev_row: Optional[Dict[str, Any]] = None, herdr_server: str = "") -> str: os.makedirs(dpath, exist_ok=True) marker_path = os.path.join(dpath, f"{session}.json") payload: Dict[str, Any] = { @@ -184,6 +229,7 @@ def write_marker(dpath: str, session: str, owner_pid: int, cwd: str, agent: str, "owner_pid": int(owner_pid), "cwd": canonical_cwd(cwd), "agent": agent, + "herdr_server": herdr_server, "at": int(time.time()), } if isinstance(prev_row, dict): @@ -203,7 +249,9 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, uuid: str = "", yaml_path: Optional[str] = None, home: Optional[str] = None, wait_s_val: Optional[int] = None, poll: float = 0.25, - is_alive: Callable[[int], bool] = pid_alive) -> int: + is_alive: Callable[[int], bool] = pid_alive, + herdr_server: str = "", + pane_alive: Optional[Callable[[str, str], bool]] = None) -> int: adapter = get_adapter(agent) if adapter is None or adapter.id_lifecycle != POST_DISCOVERED: return 0 @@ -215,6 +263,7 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, deadline = time.monotonic() + cap announced = False row_ref = {"name": session} + probe = pane_alive or herdr_session_alive while True: with open(lock_file, "a", encoding="utf-8") as lf: @@ -230,7 +279,8 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, file=sys.stderr, flush=True, ) return 1 - markers = _read_and_prune_markers(dpath, session, is_alive=is_alive) + markers = _read_and_prune_markers(dpath, session, owner_pid, state, + is_alive=is_alive, pane_alive=probe) blks = blockers(state, cwd, agent, session, markers, is_alive=is_alive) if not blks: prev_row = next( @@ -242,7 +292,8 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, ) already_recorded = bool(uuid) and own_id(prev_row, agent) == uuid if not already_recorded and owner_pid and owner_pid > 0: - write_marker(dpath, session, owner_pid, cwd, agent, prev_row=prev_row) + write_marker(dpath, session, owner_pid, cwd, agent, prev_row=prev_row, + herdr_server=herdr_server) return 0 names = ", ".join(blks) @@ -253,6 +304,15 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, f"or stop it with multi-agent-mux-stop.", file=sys.stderr, flush=True, ) + for m in markers: + if m.get("_orphan") and m.get("session") in blks: + srv = m.get("herdr_server") or "default" + print( + f"ERROR: {m['session']}'s create/resume exited before recording it, " + f"but its herdr session is alive; close it with: " + f"HERDR_SESSION_NAME={srv} herdr kill-session -t {m['session']}", + file=sys.stderr, flush=True, + ) return 1 if not announced: print( @@ -275,6 +335,7 @@ def main(argv: Optional[List[str]] = None) -> int: s_wait.add_argument("--session", required=True) s_wait.add_argument("--owner-pid", type=int, default=0) s_wait.add_argument("--uuid", default="") + s_wait.add_argument("--herdr-server", default="") a = p.parse_args(argv) return wait( cwd=a.cwd, @@ -282,6 +343,7 @@ def main(argv: Optional[List[str]] = None) -> int: session=a.session, owner_pid=a.owner_pid, uuid=a.uuid or "", + herdr_server=a.herdr_server or "", ) diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index 0e95446..90d1b2a 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -25,7 +25,15 @@ 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") +pytestmark = pytest.mark.usefixtures("own_workspace_root", "no_herdr_probe") + + +@pytest.fixture +def no_herdr_probe(monkeypatch): + """In-process id_order.wait never asks the real herdr about a dead-owner + marker's session: it reads as gone unless a test passes pane_alive.""" + from lib_py.agents import id_order + monkeypatch.setattr(id_order, "herdr_session_alive", lambda server, session: False) # --------------------------------------------------------------------------- helpers @@ -1034,3 +1042,134 @@ def test_id_order_resolved_but_unrecorded_resume_uuid_reserves_startup_until_rec proc_b.kill() proc_a.wait() proc_b.wait() + + +def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive( + tmp_path, monkeypatch, capsys +): + """PR #69 review: a create/resume killed after spawning its pane but before + registering its row leaves a dead-owner marker. It keeps blocking while the + marker's herdr session is alive, goes once the row is registered (the row + decides) or the session is gone; a live attempt on the same session name is + not pruned; a running row without a pane pid blocks (fail-closed).""" + from lib_py.agents import id_order + + home = tmp_path / "home" + ws = tmp_path / "ws" + ws.mkdir(parents=True) + yaml_path = tmp_path / "agent-sessions.yaml" + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": []})) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path)) + dpath = id_order.order_dir(str(ws), "agy", home=str(home)) + marker_a = Path(dpath) / "orphan-a-agy.json" + + alive_sessions = {"orphan-a-agy"} + probes = [] + + def pane_alive(server, session): + probes.append((server, session)) + return session in alive_sessions + + def wait_b(**kw): + return id_order.wait(str(ws), "agy", "new-b-agy", os.getpid(), yaml_path=str(yaml_path), + home=str(home), wait_s_val=0, pane_alive=pane_alive, **kw) + + dead = subprocess.Popen(["true"]) + dead.wait() + live = subprocess.Popen(["sleep", "120"]) + try: + # Orphan: owner dead, no row, herdr session alive -> blocks, marker kept, kill hint printed + id_order.write_marker(dpath, "orphan-a-agy", dead.pid, str(ws), "agy", herdr_server="srv-x") + capsys.readouterr() + assert wait_b() == 1 + assert marker_a.exists() + assert ("srv-x", "orphan-a-agy") in probes + err = capsys.readouterr().err + assert "HERDR_SESSION_NAME=srv-x herdr kill-session -t orphan-a-agy" in err + + # Row registered for the attempt -> marker pruned without a probe; the row decides + probes.clear() + epoch = int(time.time()) + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [ + _row("orphan-a-agy", "agy", ws, epoch, pid=live.pid)]})) + assert wait_b() == 1 + assert not marker_a.exists() and probes == [] + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [ + _row("orphan-a-agy", "agy", ws, epoch, pid=live.pid, agy_conversation_id_own=UA)]})) + assert wait_b() == 0 + + # Session gone -> pruned, B admitted + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": []})) + id_order.write_marker(dpath, "orphan-a-agy", dead.pid, str(ws), "agy", herdr_server="srv-x") + alive_sessions.clear() + assert wait_b() == 0 + assert not marker_a.exists() + + # A live attempt on the same session name is kept and blocks; a dead one is taken over + same = Path(dpath) / "same-agy.json" + id_order.write_marker(dpath, "same-agy", live.pid, str(ws), "agy") + assert id_order.wait(str(ws), "agy", "same-agy", os.getpid(), yaml_path=str(yaml_path), + home=str(home), wait_s_val=0, pane_alive=pane_alive) == 1 + assert json.loads(same.read_text())["owner_pid"] == live.pid + live.kill() + live.wait() + assert id_order.wait(str(ws), "agy", "same-agy", os.getpid(), yaml_path=str(yaml_path), + home=str(home), wait_s_val=0, pane_alive=pane_alive) == 0 + assert json.loads(same.read_text())["owner_pid"] == os.getpid() + same.unlink() + + # Non-blocking review note: a running same-cwd row without a pane pid blocks + no_pid = _row("no-pid-agy", "agy", ws, epoch) + no_pid["pane"].pop("pid") + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [no_pid]})) + assert wait_b() == 1 + finally: + live.kill() + live.wait() + + +def test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create( + mam_sandbox, mock_herdr, mock_agents +): + """PR #69 review reproduction: A's create spawned its pane and was killed + before appending its row (dead-owner marker, live herdr agent, no row). + create B refuses while A's herdr session is alive and proceeds once it is gone.""" + from lib_py.agents import id_order + + ws = mam_sandbox / "orphan-ws" + ws.mkdir(parents=True, exist_ok=True) + name_a, name_b = "orphan-ws-creator-agy", "orphan-ws-reviewer-agy" + create_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + + dead = subprocess.Popen(["true"]) + dead.wait() + holder = subprocess.Popen(["sleep", "120"]) + try: + dpath = id_order.order_dir(str(ws), "agy", home=str(mam_sandbox)) + id_order.write_marker(dpath, name_a, dead.pid, str(ws), "agy", herdr_server="orphan-ws") + _mock_herdr_agent(mock_herdr, name_a, ws, holder.pid, int(time.time()), pane_id="w1:p_a") + + def create_b(): + return subprocess.run( + ["bash", str(create_sh), "--workspace", str(ws), "--agent", "agy", + "--role", "reviewer", "--session", name_b, "--no-onboard"], + capture_output=True, text=True, cwd=str(mam_sandbox), + env=dict(os.environ, MAM_ID_ORDER_WAIT_S="0"), timeout=120, + ) + + res = create_b() + assert res.returncode == 1, res.stdout + res.stderr + assert name_a in res.stderr and "kill-session -t " + name_a in res.stderr + assert name_b not in _read_rows(mam_sandbox) + assert name_b not in json.loads(Path(mock_herdr).read_text()).get("agents", {}) + + state = json.loads(Path(mock_herdr).read_text()) + state["agents"].pop(name_a) + Path(mock_herdr).write_text(json.dumps(state)) + res = create_b() + assert res.returncode == 0, res.stdout + res.stderr + assert _read_rows(mam_sandbox)[name_b]["status"] == "running" + finally: + holder.kill() + holder.wait() -- 2.54.0 From 8f64174b6b8b5b64eaa06c0530f552b3440f46b5 Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 23 Oct 2026 10:07:54 +0900 Subject: [PATCH 11/14] fix(id_order): prune an orphaned marker only on an authoritative "gone", within the wait cap (Issue #63) PR #69 review round 2. - The herdr probe is tri-state (present / absent / unknown) from a parsed `_herdr agent list`: absent only when the list parses without the name or herdr answers server_not_running. A failed, unparseable or timed-out lookup is unknown and keeps the marker (codex P1: has-session's exit 1 meant both "absent" and "lookup failed"). - Probes run with MAM_NO_AUTOSTART=1 so a check never starts a server (agy P2). - Probe time is capped by max(deadline, start + PROBE_FLOOR_S=2 s), shared by all markers, so a slow herdr cannot hold the wait or its lock past the cap (codex P2); MAM_ID_ORDER_WAIT_S=0 still gets one bounded look. - Tests: unknown probe keeps and refuses; delayed probe at caps 0/1/3 stays within the bound; real probe via lib.sh mam_id_order_wait against mock herdr (listed / unlisted / failing list / server_not_running). Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib_py/agents/id_order.py | 87 +++++++++++----- tests/test_issue63_same_cwd_ids.py | 122 +++++++++++++++++++++-- 2 files changed, 179 insertions(+), 30 deletions(-) diff --git a/.agents/skills/lib_py/agents/id_order.py b/.agents/skills/lib_py/agents/id_order.py index 37ca3c2..9419cff 100644 --- a/.agents/skills/lib_py/agents/id_order.py +++ b/.agents/skills/lib_py/agents/id_order.py @@ -5,8 +5,8 @@ While a post-discovered agent (agy, codex, hermes, opencode) in a workspace is without a recorded conversation id (live pane pid), a second create or resume of the same agent type in that workspace waits until the first records its id, up to MAM_ID_ORDER_WAIT_S (default 600 s; 0 = do not wait). A marker whose owner -died before the row was registered keeps blocking while its herdr session is -alive (the pane may still discover the workspace id). +died before the row was registered keeps blocking until herdr answers that its +session is gone (the pane may still discover the workspace id). """ from __future__ import annotations @@ -25,10 +25,15 @@ from lib_py import ints from lib_py.agents.autopin import claimed_elsewhere from lib_py.agents.base import POST_DISCOVERED from lib_py.agents.registry import agent_of_row, get_adapter +from lib_py.agents.sanitize import sanitize_herdr_agent_name from lib_py.agents.session_ids import canonical_cwd, claiming_rows, own_id from lib_py.state import load_state_json DEFAULT_WAIT_S = 600 +# herdr probes of dead-owner markers share this budget past the wait deadline, +# so MAM_ID_ORDER_WAIT_S=0 still gets one look at whether an orphan is gone. +PROBE_FLOOR_S = 2.0 +PROBE_TIMEOUT_S = 30.0 def wait_s(env: Mapping[str, str] = os.environ) -> int: @@ -53,22 +58,48 @@ def pid_alive(pid: int) -> bool: return False -def herdr_session_alive(server: str, session: str) -> bool: - """lib.sh `_herdr has-session` on the marker's herdr server; an unanswered - probe counts as alive (the wait stays bounded by MAM_ID_ORDER_WAIT_S).""" +def _server_not_running(*texts: str) -> bool: + """herdr's own error envelope for a server that is down (lib.sh ISSUE-9).""" + for text in texts: + for line in [text] + text.splitlines(): + try: + doc = json.loads(line) + except ValueError: + continue + err = doc.get("error") if isinstance(doc, dict) else None + if isinstance(err, dict) and err.get("code") == "server_not_running": + return True + return False + + +def herdr_session_state(server: str, session: str, timeout: float) -> Optional[bool]: + """Is `session` live on herdr server `server`: True, False, or None when + herdr gave no authoritative answer. False only from a parsed `agent list` + without the name or herdr's server_not_running error; a failed, timed-out + or unparseable lookup is None. Never starts a server (MAM_NO_AUTOSTART).""" + if timeout <= 0: + return None skills_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - env = dict(os.environ) - if server: - env["HERDR_SESSION_NAME"] = server - script = 'source "$1" && _herdr has-session -t "$2"' + env = dict(os.environ, MAM_NO_AUTOSTART="1", HERDR_SESSION_NAME=server or "default") + script = 'source "$1" && _herdr agent list' try: - return subprocess.run( - ["bash", "-c", script, "_", os.path.join(skills_dir, "lib.sh"), session], - env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, timeout=30, - ).returncode == 0 + res = subprocess.run( + ["bash", "-c", script, "_", os.path.join(skills_dir, "lib.sh")], + env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, + timeout=min(timeout, PROBE_TIMEOUT_S), + ) except (OSError, subprocess.SubprocessError): - return True + return None + if res.returncode != 0: + return False if _server_not_running(res.stdout, res.stderr) else None + try: + agents = json.loads(res.stdout)["result"]["agents"] + except (ValueError, KeyError, TypeError): + return None + if not isinstance(agents, list): + return None + names = {session, sanitize_herdr_agent_name(session)} + return any(isinstance(a, dict) and a.get("name") in names for a in agents) def order_dir(cwd: str, agent: str, env: Mapping[str, str] = os.environ, @@ -167,12 +198,13 @@ def _registered_for_attempt(m: Dict[str, Any], row: Optional[Dict[str, Any]]) -> def _read_and_prune_markers(dpath: str, me: str, my_pid: int, state: Dict[str, Any], is_alive: Callable[[int], bool] = pid_alive, - pane_alive: Callable[[str, str], bool] = herdr_session_alive, + pane_state: Callable[[str, str], Optional[bool]] = ( + lambda server, session: None), ) -> List[Dict[str, Any]]: """Markers that still count. A live owner keeps its marker (another live attempt on `me` included). A dead owner's marker goes once its row is - registered for the attempt (the row decides from then on) or its herdr - session is gone; until then it is kept as an orphan.""" + registered for the attempt (the row decides from then on) or herdr answers + that its session is gone; until then it is kept as an orphan.""" rows_by_name = { str(s.get("name") or ""): s for s in (state.get("herdr_sessions") or []) if isinstance(s, dict) @@ -208,8 +240,9 @@ def _read_and_prune_markers(dpath: str, me: str, my_pid: int, state: Dict[str, A elif _registered_for_attempt(data, rows_by_name.get(sess)): drop = True else: - drop = not pane_alive(str(data.get("herdr_server") or ""), sess) - data["_orphan"] = True + live = pane_state(str(data.get("herdr_server") or ""), sess) + drop = live is False + data["_orphan"] = "alive" if live else "unknown" if drop: try: os.remove(fpath) @@ -251,7 +284,7 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, poll: float = 0.25, is_alive: Callable[[int], bool] = pid_alive, herdr_server: str = "", - pane_alive: Optional[Callable[[str, str], bool]] = None) -> int: + pane_state: Optional[Callable[[str, str, float], Optional[bool]]] = None) -> int: adapter = get_adapter(agent) if adapter is None or adapter.id_lifecycle != POST_DISCOVERED: return 0 @@ -261,9 +294,13 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, os.makedirs(dpath, exist_ok=True) lock_file = os.path.join(dpath, "lock") deadline = time.monotonic() + cap + probe_until = max(deadline, time.monotonic() + PROBE_FLOOR_S) announced = False row_ref = {"name": session} - probe = pane_alive or herdr_session_alive + probe = pane_state or herdr_session_state + + def bounded_probe(server: str, sess: str) -> Optional[bool]: + return probe(server, sess, probe_until - time.monotonic()) while True: with open(lock_file, "a", encoding="utf-8") as lf: @@ -280,7 +317,7 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, ) return 1 markers = _read_and_prune_markers(dpath, session, owner_pid, state, - is_alive=is_alive, pane_alive=probe) + is_alive=is_alive, pane_state=bounded_probe) blks = blockers(state, cwd, agent, session, markers, is_alive=is_alive) if not blks: prev_row = next( @@ -307,9 +344,11 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, for m in markers: if m.get("_orphan") and m.get("session") in blks: srv = m.get("herdr_server") or "default" + seen = ("its herdr session is alive" if m["_orphan"] == "alive" + else "herdr could not tell whether its session is gone") print( f"ERROR: {m['session']}'s create/resume exited before recording it, " - f"but its herdr session is alive; close it with: " + f"and {seen}; close it with: " f"HERDR_SESSION_NAME={srv} herdr kill-session -t {m['session']}", file=sys.stderr, flush=True, ) diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index 90d1b2a..9b891fe 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -31,9 +31,9 @@ pytestmark = pytest.mark.usefixtures("own_workspace_root", "no_herdr_probe") @pytest.fixture def no_herdr_probe(monkeypatch): """In-process id_order.wait never asks the real herdr about a dead-owner - marker's session: it reads as gone unless a test passes pane_alive.""" + marker's session: it reads as gone unless a test passes pane_state.""" from lib_py.agents import id_order - monkeypatch.setattr(id_order, "herdr_session_alive", lambda server, session: False) + monkeypatch.setattr(id_order, "herdr_session_state", lambda server, session, timeout: False) # --------------------------------------------------------------------------- helpers @@ -1065,15 +1065,18 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_ marker_a = Path(dpath) / "orphan-a-agy.json" alive_sessions = {"orphan-a-agy"} + unknown_sessions = set() probes = [] - def pane_alive(server, session): + def pane_state(server, session, timeout): probes.append((server, session)) + if session in unknown_sessions: + return None return session in alive_sessions def wait_b(**kw): return id_order.wait(str(ws), "agy", "new-b-agy", os.getpid(), yaml_path=str(yaml_path), - home=str(home), wait_s_val=0, pane_alive=pane_alive, **kw) + home=str(home), wait_s_val=0, pane_state=pane_state, **kw) dead = subprocess.Popen(["true"]) dead.wait() @@ -1088,6 +1091,13 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_ err = capsys.readouterr().err assert "HERDR_SESSION_NAME=srv-x herdr kill-session -t orphan-a-agy" in err + # No authoritative answer from herdr -> kept and blocks as well + unknown_sessions.add("orphan-a-agy") + assert wait_b() == 1 + assert marker_a.exists() + assert "herdr could not tell whether its session is gone" in capsys.readouterr().err + unknown_sessions.clear() + # Row registered for the attempt -> marker pruned without a probe; the row decides probes.clear() epoch = int(time.time()) @@ -1110,12 +1120,12 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_ same = Path(dpath) / "same-agy.json" id_order.write_marker(dpath, "same-agy", live.pid, str(ws), "agy") assert id_order.wait(str(ws), "agy", "same-agy", os.getpid(), yaml_path=str(yaml_path), - home=str(home), wait_s_val=0, pane_alive=pane_alive) == 1 + home=str(home), wait_s_val=0, pane_state=pane_state) == 1 assert json.loads(same.read_text())["owner_pid"] == live.pid live.kill() live.wait() assert id_order.wait(str(ws), "agy", "same-agy", os.getpid(), yaml_path=str(yaml_path), - home=str(home), wait_s_val=0, pane_alive=pane_alive) == 0 + home=str(home), wait_s_val=0, pane_state=pane_state) == 0 assert json.loads(same.read_text())["owner_pid"] == os.getpid() same.unlink() @@ -1173,3 +1183,103 @@ def test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create( finally: holder.kill() holder.wait() + + +def test_id_order_herdr_probes_stay_within_the_wait_cap(tmp_path, monkeypatch): + """PR #69 review r2 P2: probes of dead-owner markers get the time left until + max(deadline, start + PROBE_FLOOR_S), shared by all markers, so a slow herdr + cannot hold the wait (and its lock) past the cap; an expired probe is unknown.""" + from lib_py.agents import id_order + + home = tmp_path / "home" + ws = tmp_path / "ws" + ws.mkdir(parents=True) + yaml_path = tmp_path / "agent-sessions.yaml" + yaml_path.write_text(yaml.safe_dump({"herdr_sessions": []})) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path)) + dpath = id_order.order_dir(str(ws), "agy", home=str(home)) + dead = subprocess.Popen(["true"]) + dead.wait() + for name in ("orphan-1-agy", "orphan-2-agy"): + id_order.write_marker(dpath, name, dead.pid, str(ws), "agy", herdr_server="srv") + + budgets = [] + + def slow_probe(server, session, timeout): + budgets.append(timeout) + if timeout <= 0: + return None + time.sleep(min(timeout, 5.0)) + return None + + for cap in (0, 1, 3): + budgets.clear() + t0 = time.monotonic() + rc = id_order.wait(str(ws), "agy", "new-agy", os.getpid(), yaml_path=str(yaml_path), + home=str(home), wait_s_val=cap, poll=0.05, pane_state=slow_probe) + elapsed = time.monotonic() - t0 + bound = max(cap, id_order.PROBE_FLOOR_S) + assert rc == 1, cap + assert elapsed < bound + 0.6, (cap, elapsed) + assert budgets and max(budgets) <= bound + 0.01, (cap, budgets) + assert len(list(Path(dpath).glob("orphan-*.json"))) == 2, cap + + +def test_id_order_real_probe_against_mock_herdr_fails_closed(mam_sandbox, mock_herdr): + """PR #69 review r2 P1: lib.sh mam_id_order_wait with the real probe (sandbox + copy, mock herdr). A listed orphan blocks; an unlisted one is pruned; a failed + `agent list` keeps the marker and refuses; herdr's server_not_running prunes.""" + from lib_py.agents import id_order + + ws = mam_sandbox / "probe-ws" + ws.mkdir(parents=True, exist_ok=True) + name_a, name_b = "probe-ws-creator-agy", "probe-ws-reviewer-agy" + lib = mam_sandbox / ".agents" / "skills" / "lib.sh" + dpath = id_order.order_dir(str(ws), "agy", home=str(mam_sandbox)) + marker_a = Path(dpath) / f"{name_a}.json" + dead = subprocess.Popen(["true"]) + dead.wait() + holder = subprocess.Popen(["sleep", "120"]) + + def set_mock(**kw): + state = json.loads(Path(mock_herdr).read_text()) + state.pop("force_error_code", None) + state["agents"].pop(name_a, None) + state.update(kw) + Path(mock_herdr).write_text(json.dumps(state)) + + def wait_b(): + for f in Path(dpath).glob(f"{name_b}.json"): + f.unlink() + id_order.write_marker(dpath, name_a, dead.pid, str(ws), "agy", herdr_server="probe-ws") + return subprocess.run( + ["bash", "-c", 'source "$1" && mam_id_order_wait "$2" agy "$3"', "_", str(lib), str(ws), name_b], + capture_output=True, text=True, cwd=str(mam_sandbox), timeout=120, + env=dict(os.environ, MAM_ID_ORDER_WAIT_S="0", HERDR_SESSION_NAME="probe-ws"), + ) + + try: + _mock_herdr_agent(mock_herdr, name_a, ws, holder.pid, int(time.time()), pane_id="w1:p_a") + res = wait_b() + assert res.returncode == 1 and "its herdr session is alive" in res.stderr, res.stderr + assert marker_a.exists() + + set_mock(force_error_code="protocol_mismatch") + res = wait_b() + assert res.returncode == 1, res.stderr + assert "herdr could not tell whether its session is gone" in res.stderr + assert marker_a.exists() + + set_mock(force_error_code="server_not_running") + res = wait_b() + assert res.returncode == 0, res.stderr + assert not marker_a.exists() + + set_mock() + res = wait_b() + assert res.returncode == 0, res.stderr + assert not marker_a.exists() + finally: + holder.kill() + holder.wait() -- 2.54.0 From 7cac6f613e55bd34d61f8c91a42f50d2342da5f5 Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 23 Oct 2026 10:24:39 +0900 Subject: [PATCH 12/14] fix(id_order): server_not_running is unknown and cap 0 asks no herdr (Issue #63) PR #69 review round 3 (rebuttal overruled; codex r2 applied as written). - A failed `agent list`, herdr's server_not_running included, is unknown and keeps an orphaned marker; only a successful list without the name prunes. - No probe floor: probes get only the time left until the wait deadline, so MAM_ID_ORDER_WAIT_S=0 asks herdr nothing (unknown, kept) and the refusal says so. - The timeout message reports the last definite answer of this wait, so an alive orphan whose final probe ran out of time still reads as alive. - Tests follow: server_not_running and cap 0 keep the marker and refuse; delayed-probe bound is the cap itself; probing tests use a small cap. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib_py/agents/id_order.py | 39 +++++++++--------------- tests/test_issue63_same_cwd_ids.py | 30 ++++++++++++------ 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/.agents/skills/lib_py/agents/id_order.py b/.agents/skills/lib_py/agents/id_order.py index 9419cff..282bfe7 100644 --- a/.agents/skills/lib_py/agents/id_order.py +++ b/.agents/skills/lib_py/agents/id_order.py @@ -30,9 +30,6 @@ from lib_py.agents.session_ids import canonical_cwd, claiming_rows, own_id from lib_py.state import load_state_json DEFAULT_WAIT_S = 600 -# herdr probes of dead-owner markers share this budget past the wait deadline, -# so MAM_ID_ORDER_WAIT_S=0 still gets one look at whether an orphan is gone. -PROBE_FLOOR_S = 2.0 PROBE_TIMEOUT_S = 30.0 @@ -58,25 +55,12 @@ def pid_alive(pid: int) -> bool: return False -def _server_not_running(*texts: str) -> bool: - """herdr's own error envelope for a server that is down (lib.sh ISSUE-9).""" - for text in texts: - for line in [text] + text.splitlines(): - try: - doc = json.loads(line) - except ValueError: - continue - err = doc.get("error") if isinstance(doc, dict) else None - if isinstance(err, dict) and err.get("code") == "server_not_running": - return True - return False - - def herdr_session_state(server: str, session: str, timeout: float) -> Optional[bool]: """Is `session` live on herdr server `server`: True, False, or None when - herdr gave no authoritative answer. False only from a parsed `agent list` - without the name or herdr's server_not_running error; a failed, timed-out - or unparseable lookup is None. Never starts a server (MAM_NO_AUTOSTART).""" + herdr gave no authoritative answer. False only from a successful, parsed + `agent list` without the name; a failed (server down included), timed-out + or unparseable lookup, or no time left, is None. Never starts a server + (MAM_NO_AUTOSTART).""" if timeout <= 0: return None skills_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -91,7 +75,7 @@ def herdr_session_state(server: str, session: str, timeout: float) -> Optional[b except (OSError, subprocess.SubprocessError): return None if res.returncode != 0: - return False if _server_not_running(res.stdout, res.stderr) else None + return None try: agents = json.loads(res.stdout)["result"]["agents"] except (ValueError, KeyError, TypeError): @@ -294,13 +278,17 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, os.makedirs(dpath, exist_ok=True) lock_file = os.path.join(dpath, "lock") deadline = time.monotonic() + cap - probe_until = max(deadline, time.monotonic() + PROBE_FLOOR_S) announced = False row_ref = {"name": session} probe = pane_state or herdr_session_state + answers: Dict[str, bool] = {} # last definite answer per session, for the message + def bounded_probe(server: str, sess: str) -> Optional[bool]: - return probe(server, sess, probe_until - time.monotonic()) + live = probe(server, sess, deadline - time.monotonic()) + if live is not None: + answers[sess] = live + return live while True: with open(lock_file, "a", encoding="utf-8") as lf: @@ -344,8 +332,11 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, for m in markers: if m.get("_orphan") and m.get("session") in blks: srv = m.get("herdr_server") or "default" - seen = ("its herdr session is alive" if m["_orphan"] == "alive" + alive = m["_orphan"] == "alive" or answers.get(m["session"]) is True + seen = ("its herdr session is alive" if alive else "herdr could not tell whether its session is gone") + if not alive and cap == 0: + seen += " (MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr)" print( f"ERROR: {m['session']}'s create/resume exited before recording it, " f"and {seen}; close it with: " diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index 9b891fe..3d836bf 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -1165,12 +1165,13 @@ def test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create( ["bash", str(create_sh), "--workspace", str(ws), "--agent", "agy", "--role", "reviewer", "--session", name_b, "--no-onboard"], capture_output=True, text=True, cwd=str(mam_sandbox), - env=dict(os.environ, MAM_ID_ORDER_WAIT_S="0"), timeout=120, + env=dict(os.environ, MAM_ID_ORDER_WAIT_S="3"), timeout=120, ) res = create_b() assert res.returncode == 1, res.stdout + res.stderr assert name_a in res.stderr and "kill-session -t " + name_a in res.stderr + assert "its herdr session is alive" in res.stderr assert name_b not in _read_rows(mam_sandbox) assert name_b not in json.loads(Path(mock_herdr).read_text()).get("agents", {}) @@ -1186,9 +1187,9 @@ def test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create( def test_id_order_herdr_probes_stay_within_the_wait_cap(tmp_path, monkeypatch): - """PR #69 review r2 P2: probes of dead-owner markers get the time left until - max(deadline, start + PROBE_FLOOR_S), shared by all markers, so a slow herdr - cannot hold the wait (and its lock) past the cap; an expired probe is unknown.""" + """PR #69 review r2 P2: probes of dead-owner markers get only the time left + until the wait deadline, shared by all markers, so a slow herdr cannot hold + the wait (and its lock) past the cap; cap 0 asks nothing (unknown, kept).""" from lib_py.agents import id_order home = tmp_path / "home" @@ -1219,17 +1220,20 @@ def test_id_order_herdr_probes_stay_within_the_wait_cap(tmp_path, monkeypatch): rc = id_order.wait(str(ws), "agy", "new-agy", os.getpid(), yaml_path=str(yaml_path), home=str(home), wait_s_val=cap, poll=0.05, pane_state=slow_probe) elapsed = time.monotonic() - t0 - bound = max(cap, id_order.PROBE_FLOOR_S) + bound = cap assert rc == 1, cap assert elapsed < bound + 0.6, (cap, elapsed) assert budgets and max(budgets) <= bound + 0.01, (cap, budgets) + if cap == 0: + assert elapsed < 0.3 and all(b <= 0 for b in budgets), (elapsed, budgets) assert len(list(Path(dpath).glob("orphan-*.json"))) == 2, cap def test_id_order_real_probe_against_mock_herdr_fails_closed(mam_sandbox, mock_herdr): """PR #69 review r2 P1: lib.sh mam_id_order_wait with the real probe (sandbox copy, mock herdr). A listed orphan blocks; an unlisted one is pruned; a failed - `agent list` keeps the marker and refuses; herdr's server_not_running prunes.""" + `agent list` (server_not_running included) keeps the marker and refuses; + with MAM_ID_ORDER_WAIT_S=0 herdr is not asked and the marker is kept.""" from lib_py.agents import id_order ws = mam_sandbox / "probe-ws" @@ -1249,14 +1253,14 @@ def test_id_order_real_probe_against_mock_herdr_fails_closed(mam_sandbox, mock_h state.update(kw) Path(mock_herdr).write_text(json.dumps(state)) - def wait_b(): + def wait_b(cap="2"): for f in Path(dpath).glob(f"{name_b}.json"): f.unlink() id_order.write_marker(dpath, name_a, dead.pid, str(ws), "agy", herdr_server="probe-ws") return subprocess.run( ["bash", "-c", 'source "$1" && mam_id_order_wait "$2" agy "$3"', "_", str(lib), str(ws), name_b], capture_output=True, text=True, cwd=str(mam_sandbox), timeout=120, - env=dict(os.environ, MAM_ID_ORDER_WAIT_S="0", HERDR_SESSION_NAME="probe-ws"), + env=dict(os.environ, MAM_ID_ORDER_WAIT_S=cap, HERDR_SESSION_NAME="probe-ws"), ) try: @@ -1273,10 +1277,16 @@ def test_id_order_real_probe_against_mock_herdr_fails_closed(mam_sandbox, mock_h set_mock(force_error_code="server_not_running") res = wait_b() - assert res.returncode == 0, res.stderr - assert not marker_a.exists() + assert res.returncode == 1, res.stderr + assert "herdr could not tell whether its session is gone" in res.stderr + assert marker_a.exists() set_mock() + res = wait_b(cap="0") + assert res.returncode == 1, res.stderr + assert "MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr" in res.stderr + assert marker_a.exists() + res = wait_b() assert res.returncode == 0, res.stderr assert not marker_a.exists() -- 2.54.0 From 1614af5b673d7519d3a38f63e3636055592b4f14 Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 23 Oct 2026 11:47:04 +0900 Subject: [PATCH 13/14] fix(id_order): a dead-owner marker on the caller's own session follows the orphan rules (Issue #63) A marker left on the invoking session name by a killed create/resume was pruned before herdr was asked, so a retry under the same name took it over while the orphaned pane was still alive (and create's failure cleanup could then kill it). It is now kept and blocks like any other orphan until its row is registered for the attempt or herdr reports the session gone. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib_py/agents/id_order.py | 13 +++---- tests/test_issue63_same_cwd_ids.py | 45 ++++++++++++++++++------ 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/.agents/skills/lib_py/agents/id_order.py b/.agents/skills/lib_py/agents/id_order.py index 282bfe7..d4215d5 100644 --- a/.agents/skills/lib_py/agents/id_order.py +++ b/.agents/skills/lib_py/agents/id_order.py @@ -180,15 +180,16 @@ def _registered_for_attempt(m: Dict[str, Any], row: Optional[Dict[str, Any]]) -> return int(row.get("herdr_session_epoch") or 0) > int(m.get("prev_epoch") or 0) -def _read_and_prune_markers(dpath: str, me: str, my_pid: int, state: Dict[str, Any], +def _read_and_prune_markers(dpath: str, my_pid: int, state: Dict[str, Any], is_alive: Callable[[int], bool] = pid_alive, pane_state: Callable[[str, str], Optional[bool]] = ( lambda server, session: None), ) -> List[Dict[str, Any]]: """Markers that still count. A live owner keeps its marker (another live - attempt on `me` included). A dead owner's marker goes once its row is - registered for the attempt (the row decides from then on) or herdr answers - that its session is gone; until then it is kept as an orphan.""" + attempt on the caller's session included). A dead owner's marker goes once + its row is registered for the attempt (the row decides from then on) or + herdr answers that its session is gone; until then it is kept as an orphan, + one an earlier attempt left on the caller's session included.""" rows_by_name = { str(s.get("name") or ""): s for s in (state.get("herdr_sessions") or []) if isinstance(s, dict) @@ -219,7 +220,7 @@ def _read_and_prune_markers(dpath: str, me: str, my_pid: int, state: Dict[str, A drop = True elif is_alive(owner_pid): drop = False - elif sess == me or not sess: + elif not sess: drop = True elif _registered_for_attempt(data, rows_by_name.get(sess)): drop = True @@ -304,7 +305,7 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int, file=sys.stderr, flush=True, ) return 1 - markers = _read_and_prune_markers(dpath, session, owner_pid, state, + markers = _read_and_prune_markers(dpath, owner_pid, state, is_alive=is_alive, pane_state=bounded_probe) blks = blockers(state, cwd, agent, session, markers, is_alive=is_alive) if not blks: diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index 3d836bf..d9a2efa 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -1050,8 +1050,9 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_ """PR #69 review: a create/resume killed after spawning its pane but before registering its row leaves a dead-owner marker. It keeps blocking while the marker's herdr session is alive, goes once the row is registered (the row - decides) or the session is gone; a live attempt on the same session name is - not pruned; a running row without a pane pid blocks (fail-closed).""" + decides) or the session is gone; on the same session name a live attempt is + not pruned and a dead one follows the orphan rules (PR #69 review r3); a + running row without a pane pid blocks (fail-closed).""" from lib_py.agents import id_order home = tmp_path / "home" @@ -1116,16 +1117,33 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_ assert wait_b() == 0 assert not marker_a.exists() - # A live attempt on the same session name is kept and blocks; a dead one is taken over + # Same session name: a live attempt is kept and blocks; a dead one is kept and blocks + # while its herdr session is alive or herdr cannot tell, and is taken over once gone same = Path(dpath) / "same-agy.json" + + def wait_same(): + return id_order.wait(str(ws), "agy", "same-agy", os.getpid(), yaml_path=str(yaml_path), + home=str(home), wait_s_val=0, pane_state=pane_state) + id_order.write_marker(dpath, "same-agy", live.pid, str(ws), "agy") - assert id_order.wait(str(ws), "agy", "same-agy", os.getpid(), yaml_path=str(yaml_path), - home=str(home), wait_s_val=0, pane_state=pane_state) == 1 + assert wait_same() == 1 assert json.loads(same.read_text())["owner_pid"] == live.pid live.kill() live.wait() - assert id_order.wait(str(ws), "agy", "same-agy", os.getpid(), yaml_path=str(yaml_path), - home=str(home), wait_s_val=0, pane_state=pane_state) == 0 + alive_sessions.add("same-agy") + probes.clear() + capsys.readouterr() + assert wait_same() == 1 + assert probes == [("", "same-agy")] + assert json.loads(same.read_text())["owner_pid"] == live.pid + err = capsys.readouterr().err + assert "its herdr session is alive" in err and "kill-session -t same-agy" in err + unknown_sessions.add("same-agy") + assert wait_same() == 1 + assert json.loads(same.read_text())["owner_pid"] == live.pid + unknown_sessions.clear() + alive_sessions.clear() + assert wait_same() == 0 assert json.loads(same.read_text())["owner_pid"] == os.getpid() same.unlink() @@ -1233,7 +1251,8 @@ def test_id_order_real_probe_against_mock_herdr_fails_closed(mam_sandbox, mock_h """PR #69 review r2 P1: lib.sh mam_id_order_wait with the real probe (sandbox copy, mock herdr). A listed orphan blocks; an unlisted one is pruned; a failed `agent list` (server_not_running included) keeps the marker and refuses; - with MAM_ID_ORDER_WAIT_S=0 herdr is not asked and the marker is kept.""" + with MAM_ID_ORDER_WAIT_S=0 herdr is not asked and the marker is kept. A + retry on the orphan's own session name is held the same way (review r3).""" from lib_py.agents import id_order ws = mam_sandbox / "probe-ws" @@ -1253,12 +1272,12 @@ def test_id_order_real_probe_against_mock_herdr_fails_closed(mam_sandbox, mock_h state.update(kw) Path(mock_herdr).write_text(json.dumps(state)) - def wait_b(cap="2"): + def wait_b(cap="2", who=name_b): for f in Path(dpath).glob(f"{name_b}.json"): f.unlink() id_order.write_marker(dpath, name_a, dead.pid, str(ws), "agy", herdr_server="probe-ws") return subprocess.run( - ["bash", "-c", 'source "$1" && mam_id_order_wait "$2" agy "$3"', "_", str(lib), str(ws), name_b], + ["bash", "-c", 'source "$1" && mam_id_order_wait "$2" agy "$3"', "_", str(lib), str(ws), who], capture_output=True, text=True, cwd=str(mam_sandbox), timeout=120, env=dict(os.environ, MAM_ID_ORDER_WAIT_S=cap, HERDR_SESSION_NAME="probe-ws"), ) @@ -1268,6 +1287,9 @@ def test_id_order_real_probe_against_mock_herdr_fails_closed(mam_sandbox, mock_h res = wait_b() assert res.returncode == 1 and "its herdr session is alive" in res.stderr, res.stderr assert marker_a.exists() + res = wait_b(who=name_a) + assert res.returncode == 1 and "its herdr session is alive" in res.stderr, res.stderr + assert json.loads(marker_a.read_text())["owner_pid"] == dead.pid set_mock(force_error_code="protocol_mismatch") res = wait_b() @@ -1290,6 +1312,9 @@ def test_id_order_real_probe_against_mock_herdr_fails_closed(mam_sandbox, mock_h res = wait_b() assert res.returncode == 0, res.stderr assert not marker_a.exists() + res = wait_b(who=name_a) + assert res.returncode == 0, res.stderr + assert json.loads(marker_a.read_text())["owner_pid"] != dead.pid finally: holder.kill() holder.wait() -- 2.54.0 From 0454fe968b0f7946acd2ca69a67d9a18250caaff Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 23 Oct 2026 15:46:08 +0900 Subject: [PATCH 14/14] release(v4.10.0): same-type agents in one cwd never take each other's conversation id; same-cwd creation order (Issue #63) - archive the review reports, bump the lockstep version Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .../report-af549b13.identity.json | 14 +++ .../report-af549b13.md | 114 ++++++++++++++++++ .../report-12669b52.identity.json | 14 +++ .../report-12669b52.md | 21 ++++ .../report-cae1ea73.identity.json | 14 +++ .../reviewer-agy-01/report-cae1ea73.md | 74 ++++++++++++ .../report-dee00bc0.identity.json | 14 +++ .../reviewer-grok-01/report-dee00bc0.md | 102 ++++++++++++++++ .agents/skills/lib.sh | 2 +- .agents/skills/lib_py/loop/eager.py | 2 +- .../skills/multi-agent-mux-create/SKILL.md | 2 +- .../multi-agent-mux-delegate-job/SKILL.md | 2 +- .agents/skills/multi-agent-mux-loop/SKILL.md | 2 +- .../skills/multi-agent-mux-monitor/SKILL.md | 2 +- .../multi-agent-mux-orc-onboard/SKILL.md | 2 +- .../skills/multi-agent-mux-resume/SKILL.md | 2 +- .../skills/multi-agent-mux-status/SKILL.md | 2 +- .agents/skills/multi-agent-mux-stop/SKILL.md | 2 +- BOOTSTRAP.ko.md | 2 +- BOOTSTRAP.md | 2 +- MESSAGING.md | 2 +- README.ko.md | 2 +- README.md | 2 +- VERSIONS.md | 54 +++++++-- 24 files changed, 425 insertions(+), 26 deletions(-) create mode 100644 .agents/reports/planner-reviewer-claude-01/report-af549b13.identity.json create mode 100644 .agents/reports/planner-reviewer-claude-01/report-af549b13.md create mode 100644 .agents/reports/planner-reviewer-codex-01/report-12669b52.identity.json create mode 100644 .agents/reports/planner-reviewer-codex-01/report-12669b52.md create mode 100644 .agents/reports/reviewer-agy-01/report-cae1ea73.identity.json create mode 100644 .agents/reports/reviewer-agy-01/report-cae1ea73.md create mode 100644 .agents/reports/reviewer-grok-01/report-dee00bc0.identity.json create mode 100644 .agents/reports/reviewer-grok-01/report-dee00bc0.md diff --git a/.agents/reports/planner-reviewer-claude-01/report-af549b13.identity.json b/.agents/reports/planner-reviewer-claude-01/report-af549b13.identity.json new file mode 100644 index 0000000..d381a75 --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-af549b13.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20261023T035928Z-2ccc02", + "base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630", + "head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14", + "tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188", + "review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14", + "reviewer": "planner-reviewer-claude-01", + "job_id": "af549b13", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-10-23T05:16:58Z" +} \ No newline at end of file diff --git a/.agents/reports/planner-reviewer-claude-01/report-af549b13.md b/.agents/reports/planner-reviewer-claude-01/report-af549b13.md new file mode 100644 index 0000000..83e6917 --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-af549b13.md @@ -0,0 +1,114 @@ +# PR #69 (Issue #63) 리뷰 — head 1614af5 + +- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6) +- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1` +- identity 확인: HEAD 1614af5b673d7519d3a38f63e3636055592b4f14, tree f1af6fdd548ee5d6f6b81e2202833edfaca98188, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD dc886ec 그대로. +- 범위: base dc886ec..1614af5 전체(커밋 13개). + - ede9c62 까지는 이전 리뷰(잡 8445af7f, 55f86b49)에서, 8f64174·7cac6f6 은 잡 1c6eca2b 에서 봤다. 그 결론을 다시 확인했다. + - 이번에는 1614af5 와 세 수정 사이의 정합성을 중점으로 봤다. +- 참고: 같은 head 를 맡은 잡 906427d1 은 관련 스위트 도중 검증 경로(`…a646cb/3`)가 사라져 끝내지 못했다. 그 잡에서 얻은 head 결과(issue63 27 passed, 변이 결과)는 같은 tree 의 스크래치 사본에서 나온 것이다. `diff -rq` 로 이번 검증 경로와 같음을 확인했다. + +## 1. 1614af5 가 바꾼 것 + +- `.agents/skills/lib_py/agents/id_order.py:223`: `elif sess == me or not sess:` 를 `elif not sess:` 로 바꿨다. + - 그래서 호출자 자신의 세션 이름에 남은 죽은 owner 마커도 고아 규칙을 따른다. 이번 시도의 행이 등록되면 지우고(:225), herdr 가 정상 목록으로 "없음"이라 답하면 지우고(:228-229), 그 밖에는 남긴다. +- `_read_and_prune_markers` 의 `me` 인자를 지웠다(:183, :308). +- `blockers` 는 ede9c62 부터 `m_sess == me` 를 건너뛰지 않는다(:136-144). 그래서 남은 자기 이름 마커가 실제로 막는다. + +## 2. 세 수정이 각 지적을 닫는가, 서로 어긋나지 않는가 + +1. **8f64174 — 조회 실패를 "없음"으로 읽던 문제** + - `herdr_session_state` (:58-86) 는 정상 파싱한 `agent list` 에 이름이 없을 때만 False 를 돌려준다. 그 밖에는 None 이다. + - prune 은 `drop = live is False` (:228) 다. + - 변이 m1(`not live`)과 m2(rc≠0 → False)가 테스트에 잡혔다(잡 1c6eca2b). +2. **7cac6f6 — 상한 0 에서 30 초 조회, server_not_running** + - 조회 시간은 `deadline - now` 다(:288). timeout≤0 이면 None 을 돌려준다(:64-65). + - 변이 m4(고정 30 s)와 m7(timeout≤0 에도 조회)이 잡혔다. + - 상한 0 테스트는 0.3 s 안에 끝나는 것을 확인한다(test :1222-1224). +3. **1614af5 — 같은 이름의 죽은 owner 마커를 확인 없이 지우던 문제** + - 옛 규칙(`sess == me` 이면 drop)을 되돌린 변이 사본에서는 두 테스트가 실패한다: `test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`. + - 커밋 메시지가 말한 "재시도의 실패 정리가 살아 있는 pane 을 죽임"도 막힌다. create 의 대기(`create_session.sh:287`)는 롤백 trap 설치(:367)보다 앞서 있어서, 대기에서 거부(exit 1)되면 `cleanup_herdr_on_error` 가 돌지 않는다. +4. **정합성**: 세 수정은 모두 `_read_and_prune_markers` 의 "죽은 owner 마커" 분기 하나를 쓰고, 조회는 `bounded_probe` 하나를 거친다. 자기 이름 마커만 다른 규칙을 쓰던 예외가 사라져서 규칙이 하나로 모였다. 어긋나는 곳은 찾지 못했다. + +## 3. 흔한 사용을 600 초 대기로 막지 않는가 + +스크래치에서 `id_order.wait` 를 프로세스 안에서 직접 불렀다. 조회 함수를 주입하고, cap 2 s(일부 0 s)로 돌렸다. + +**정상 사용 (실패 없음)**: 막지 않는다. +- create 마커가 남은 채로, 그 세션이 다음 상태일 때 같은 이름으로 resume 했다: + - stopped 이고 own id 가 기록됨 + - stopped 이고 id 없음 (herdr-dead stop) + - reconcile 이 terminated 로 바꿈 +- 세 경우 모두 herdr 가 **답하지 못해도**(None), cap 0 에서도 rc=0 이었다. +- `blockers` 의 `pinned_for_attempt` 와 `ended_after_attempt` (:158-171) 가 이 경우들을 걸러낸다. +- 정상 create/resume 이 끝난 뒤의 마커는 행이 running 인 동안에는 `_registered_for_attempt` (:177-180) 로 지워진다. + +**실패 뒤 재시도**: + +| 경우 | herdr 가 "없음" 응답 (서버 살아 있음) | herdr 무응답 (서버 꺼짐 등) | +|---|---|---| +| create X 가 행 쓰기 전에 실패(exit 42 롤백) → create X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 | +| resume X 가 spawn 에서 실패(행 그대로) → resume X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 | +| 위 resume 실패 → 같은 cwd 에서 create Y | rc=0, 0.0 s | rc=1, cap 만큼 대기 | + +- 7cac6f6 사본으로 같은 시뮬레이션을 돌리면 무응답 열의 위 두 줄(같은 이름 재시도)이 rc=0 이다. +- 즉 1614af5 는 서버가 살아 있는 흔한 재시도는 그대로 두고, herdr 가 답하지 못하는 재시도만 막는다. 이는 아래 R1 이 같은 이름까지 넓어진 것이다. + +## 4. 그 밖의 확인 + +브리프 항목 1·2·4·5·6·8(완료 조건 테스트, autopin 재사용, resume·stop, 지운 심볼, issue34 무관)은 1614af5 가 `session_ids.py`, `lib.sh`, 셸 스크립트, 문서를 건드리지 않으므로 이전 리뷰의 결론 그대로다. + +**macOS**: +- `/usr/bin/python3` 3.9.6 으로 `lib_py.agents.id_order` import 가 성공한다(잡 1c6eca2b; 1614af5 는 문법 요소를 더하지 않는다). +- 잠금은 `fcntl.flock` 이다(:294-295). + +**실제 herdr 를 건드리지 않았는가**: +- 기존 서버 두 개(pid 3407 `herdr server`, 8323 `herdr --session multi-agent-mux server`)는 스위트 전후로 같다(시작 시각 08:21). +- 스위트 중 13:22 에 `herdr --session mam-pylecturenotes server` 가 새로 보였다. 하지만 이 프로세스는 실제 HOME 과 `WORKSPACE_ROOT=/Users/godopu16/PuKi/lac/curriculum/01_python_getting_started_lecturenotes` 로 떠 있고, 사용자의 attach 클라이언트(13:27)가 붙어 있다. 테스트는 HOME 을 스크래치로 바꿔 돌므로 다른 작업 공간의 것이다. + +## 5. 실행한 테스트 + +모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=` 로 돌렸다. 테스트에서 .mam.env 를 export 하지 않았다. + +| 대상 | 결과 | +|---|---| +| 검증 경로: `tests/test_issue63_same_cwd_ids.py` + 관련 19개 모듈 (issue40_auto_pin, issue64_ro_connect, issue10_13_purge, issue31_purge_residual, issue42_skill_blocks, tier1_unit, tier2_component, b4_session_created, issue24_resume_dialogs, issue30_stall_watch_stop, issue23_24_startup_path, issue45_restore, issue50_pane_lock, c1_tui_readiness, issue6_fail_closed, issue60_opencode_v2, issue60_linux_fixes, uuid_target, issue17_workspace_root_isolation) | **597 passed, 2 skipped, 0 failed** (32 분) | +| 같은 tree 스크래치 사본: issue63 단독 | 27 passed | +| base 사본 + head 테스트 | 27 errors (`ImportError: id_order`). 조건별 base 실패는 1차에서 22 failed + 1 passed 로 확인했다 | +| 변이: 1614af5 를 되돌림(자기 이름 마커를 drop) | 2 failed (위 두 테스트) | + +## 6. 차단 결함 + +없음. + +## 7. 비차단 + +### R1 (잡 1c6eca2b 의 R1, 범위가 넓어짐): herdr 가 답하지 못하는 고아 마커는 같은 폴더·같은 종류의 create/resume 을 매번 cap 동안 붙잡는다 + +- 이제 같은 이름 재시도도 포함된다(위 표). +- 서버가 꺼진 상태(server_not_running)가 대표적이다. + - 조회는 서버를 띄우지 않는다(:67). + - create/resume 은 대기를 통과해야 spawn 으로 서버를 띄운다(`create_session.sh:287`→:289, `resume_session.sh:243`→:260). + - 그래서 다른 무언가가 서버를 켜기 전까지는 기본 600 s 를 기다린 뒤 거부가 반복된다. +- 회복 경로는 있다: 메시지의 `HERDR_SESSION_NAME= herdr kill-session -t ` 를 shim 으로 실행하면 서버가 기동된다(잡 1c6eca2b 의 가짜 herdr 재현에서 `--session srv-down server` 1회). +- 생기는 조건은 "행 등록 전 실패나 kill"에 "서버가 꺼짐"이 겹치는 경우다. 정상 사용과 서버가 살아 있는 재시도는 막지 않는다(3절). +- 권고 (둘 중 하나): + - (a) server_not_running 만은 "없음"으로 읽는다. MAM 서버는 restore 가 꺼져 있어서 꺼진 서버 위에는 살아 있는 pane 이 없다. `lib.sh:204-207` 의 ISSUE-9 규약과도 맞는다. + - (b) 그 경우는 cap 을 기다리지 말고 바로 거부한다. 메시지는 "서버가 꺼져 있음 → 위 명령으로 정리"처럼 사실대로 쓴다. +- fail-closed 를 택한 것은 루프에서 정한 설계 선택이라 차단으로 두지 않는다. + +### 그 밖의 비차단 (잡 1c6eca2b 와 같음) + +- **N1**: 조회가 `MAM_NO_AUTOSTART=1` 을 쓴다는 것(:67)을 고정하는 테스트가 없다. +- **N2**: sanitize 한 이름 비교(:85)를 고정하는 테스트가 없다. +- **N3**: `flock(LOCK_EX)` 대기(:295)는 시간 제한이 없다. 다른 호출자가 앞 대기의 남은 시간만큼 더 기다릴 수 있다. 상한은 있다. +- **N4**: create·resume SKILL.md 의 exit 1 설명이 고아 마커로 거부되는 경우와 그 정리 방법을 말하지 않는다. +- **N5**: 이전 라운드의 비차단 항목이 남아 있다: resume `mam_id_order_wait` 제거가 테스트로 고정되지 않음, c6(b) 의 1.5 s 타이밍 flake, lib.sh `--herdr-server` 전달이 테스트되지 않음. + +## 8. 결론 + +- 1614af5 는 codex 의 3차 지적(같은 이름의 죽은 owner 마커를 확인 없이 지움)을 닫는다. 8f64174·7cac6f6 과 같은 고아 규칙 하나로 모였다. +- 정상 create/resume 과 서버가 살아 있는 재시도는 막지 않는다. +- herdr 가 답하지 못할 때 cap 동안 기다리는 비용(R1)은 회복 경로가 있는 설계 선택이므로 비차단이다. + +[VERDICT: PASS] diff --git a/.agents/reports/planner-reviewer-codex-01/report-12669b52.identity.json b/.agents/reports/planner-reviewer-codex-01/report-12669b52.identity.json new file mode 100644 index 0000000..35ae99b --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-12669b52.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20261023T035928Z-2ccc02", + "base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630", + "head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14", + "tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188", + "review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14", + "reviewer": "planner-reviewer-codex-01", + "job_id": "12669b52", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-10-23T05:16:58Z" +} \ No newline at end of file diff --git a/.agents/reports/planner-reviewer-codex-01/report-12669b52.md b/.agents/reports/planner-reviewer-codex-01/report-12669b52.md new file mode 100644 index 0000000..203c160 --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-12669b52.md @@ -0,0 +1,21 @@ +# Reviewer report — PR #69 / Issue #63 + +Reviewed the required snapshot at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`, head `1614af5b673d7519d3a38f63e3636055592b4f14`, against base `dc886ecd6b38c01032a55a2d1c230c73362ae630`. The snapshot matches the expected tree `f1af6fdd548ee5d6f6b81e2202833edfaca98188` and is clean. I read Issue #63 and PR #69. The latest delta is limited to `id_order.py` and its regression tests. + +## Previously blocking finding is closed + +At the previous head (`7cac6f6`), `_read_and_prune_markers` dropped a dead-owner marker when its session name matched the caller, without checking herdr. The new code removes that exception: `.agents/skills/lib_py/agents/id_order.py:219-230` now keeps a dead-owner marker until its row is registered for that attempt or herdr returns an authoritative `False`; live and unknown answers both retain the orphan. `blockers()` includes a marker without a row even when its name is the caller's (`id_order.py:145-148`), so a retry is refused rather than replacing its marker. + +The tests now cover same-name retries with a live owner, a dead owner and live herdr session, unknown herdr state, and a confirmed-gone session (`tests/test_issue63_same_cwd_ids.py:1117-1148`). The real-probe integration test also retries using the orphan's own name and checks that the marker is retained (`tests/test_issue63_same_cwd_ids.py:1287-1290`) and then safely replaced once the session is confirmed gone (`1312-1315`). The test fixture uses a mocked herdr and in-process probes; it does not contact a real herdr server. + +## Verification + +- `PYTHONDONTWRITEBYTECODE=1 .../python -m pytest -p no:cacheprovider --basetemp=/tmp/mam-review-12669b52 tests/test_issue63_same_cwd_ids.py` — **27 passed** on macOS, Python 3.9.6 (230.59 s). This includes the orphan liveness, unknown-result, zero-wait-cap, and same-name retry cases. +- Changed shell scripts checked with `/bin/bash -n` — passed. +- `git diff --check dc886ecd6b38c01032a55a2d1c230c73362ae630..HEAD` — passed. +- The earlier review covered the broader unchanged stop/purge/resume implementation and adjacent compatibility checks. This re-review did not repeat the base checkout or all adjacent test groups because the new delta is confined to dead-owner marker pruning and its regression tests. +- No real agent-home databases or real herdr sessions were accessed. + +The latest change fixes the remaining blocking same-name orphan path. The earlier probe-failure and wait-cap fixes remain covered by the passing Issue #63 tests. I found no blocking issue in this revision. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-agy-01/report-cae1ea73.identity.json b/.agents/reports/reviewer-agy-01/report-cae1ea73.identity.json new file mode 100644 index 0000000..d4ad5a0 --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-cae1ea73.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20261023T035928Z-2ccc02", + "base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630", + "head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14", + "tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188", + "review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14", + "reviewer": "reviewer-agy-01", + "job_id": "cae1ea73", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-10-23T05:16:58Z" +} \ No newline at end of file diff --git a/.agents/reports/reviewer-agy-01/report-cae1ea73.md b/.agents/reports/reviewer-agy-01/report-cae1ea73.md new file mode 100644 index 0000000..80f6af4 --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-cae1ea73.md @@ -0,0 +1,74 @@ +# PR #69 (Issue #63) Round 3 Code Review Report — `agy` (`reviewer-agy-01`) + +- **Job ID**: `cae1ea73` +- **Branch / Range**: `fix/issue-63` (`dc886ecd6b38c01032a55a2d1c230c73362ae630..1614af5b673d7519d3a38f63e3636055592b4f14`, 13 commits) +- **Tree**: `f1af6fdd548ee5d6f6b81e2202833edfaca98188` +- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1` +- **Environment**: macOS (`Darwin arm64`), `/bin/bash 3.2.57(1)-release`, `/usr/bin/python3 3.9.6`, `.venv/bin/python 3.9.6` +- **Summary**: **PASS** (`P0: 0`, `P1: 0`, `P2: 0`) + +--- + +## 1. 2·3차 리뷰 후속 커밋 (`ede9c62`, `8f64174`, `7cac6f6`, `1614af5`) 집중 검증 결과 + +### 1.1 권위 있는 "세션 없음" 응답에서만 고아 마커 삭제 (`8f64174`, `id_order.py:58-90,225-227`) +- `herdr_session_state(server, session, timeout)` (`id_order.py:58-90`)는 `MAM_NO_AUTOSTART="1"` 및 `HERDR_SESSION_NAME=server or "default"` 환경에서 `source "$1" && _herdr agent list`를 실행합니다. +- `res.returncode == 0`이고 `json.loads(res.stdout)["result"]["agents"]`가 리스트이며, 그 안에 `session` 및 `sanitize_herdr_agent_name(session)`이 모두 없을 때에만 `False`를 반환합니다 (`id_order.py:74-83`). +- `_read_and_prune_markers` (`id_order.py:225-227`)는 `drop = live is False`로 판정하여, 조회 실패·타임아웃·파싱 실패(`live is None`) 시에는 마커를 삭제하지 않고 `_orphan = "unknown"`으로 유지합니다. + +### 1.2 `server_not_running` 불확실 처리 및 `MAM_ID_ORDER_WAIT_S=0` 무프로브 보장 (`7cac6f6`, `id_order.py:58-90,280-291,330-346`) +- `server_not_running`을 포함한 모든 비정상 종료(`res.returncode != 0`)에서 `herdr_session_state`가 `None`을 반환하므로 서버 다운 상태에서도 고아 마커가 삭제되지 않고 fail-closed로 차단됩니다. +- `bounded_probe` (`id_order.py:285-290`)가 `deadline - time.monotonic()`을 `timeout`으로 전달하고, `herdr_session_state` 첫 줄(`if timeout <= 0: return None`, `id_order.py:64-65`)에서 즉시 반환하므로: + - 다수의 죽은 마커가 있더라도 전체 프로브 시간이 `MAM_ID_ORDER_WAIT_S` 상한 안에 엄격히 묶입니다 (`test_id_order_herdr_probes_stay_within_the_wait_cap`). + - `MAM_ID_ORDER_WAIT_S=0`(`cap == 0`)일 때는 herdr 서브프로세스를 전혀 호출하지 않고 즉시 거부하며 `(MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr)` 안내를 출력합니다 (`id_order.py:336-338`). +- `answers: Dict[str, bool]` (`id_order.py:283,287-289,333`)가 이전 폴링에서 확인된 확정 응답을 보존하여 마지막 폴링 시점에 예산이 소진되더라도 `"its herdr session is alive"` 메시지를 정확히 유지합니다. + +### 1.3 동일 세션 이름(`sess == me`)의 죽은 owner 마커에 동일한 고아 규칙 적용 (`1614af5`, `id_order.py:180-235,305`) +- `_read_and_prune_markers`에서 `sess == me`일 때 무조건 삭제하던 분기를 제거하여(`id_order.py:217-227`), 이전 `create`/`resume`이 pane을 띄운 직후 죽어 고아 세션을 남긴 상태에서 **같은 세션 이름**으로 재시도하더라도: + - 고아 herdr 세션이 살아 있거나 확인 불가(`True`/`None`)이면 마커를 유지하고 차단합니다 (`blockers()`는 `m_sess == me`를 건너뛰지 않음, `id_order.py:130-145`). + - herdr가 해당 세션이 사라졌음을 확인(`False`)하면 죽은 마커를 삭제하고 새 시도의 마커로 교체(`owner_pid == my_pid`)하여 정상 진입합니다 (`test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`). + +### 1.4 세 수정 간 정합성 및 일반 사용(정상 create/resume, 재시도) 600초 대기 미발생 검증 +- **정상 `create` 후 진행 중**: 첫 세션이 YAML 행을 `status: "running"` 및 `herdr_session_epoch > prev_epoch`로 등록하면 `_registered_for_attempt` (`id_order.py:174-177`)가 `True`를 반환해 herdr 프로브 없이 마커가 즉시 삭제되고, 이후에는 YAML 행의 `own_id` 기록 여부에 따라 대기가 결정됩니다. +- **정상 `stop` 후 후속 `create`/`resume`**: 세션이 정상 중지되면 `own_id`가 기록되거나(`pinned_for_attempt == True`) `stopped_at_epoch > max(m_at, prev_end_epoch)`(`ended_after_attempt == True`)가 성립하므로 `blockers()` (`id_order.py:150-163`)가 이를 차단 목록에서 즉시 제외하여 600초 대기가 발생하지 않습니다. +- **`create` 실패·롤백(`exit 42`) 후 재시도**: `create_session.sh`의 `cleanup_herdr_on_error` 트랩(`create_session.sh:337-368`)이 실행 중인 워크스페이스 herdr 서버에서 세션을 즉시 종료하므로, 재시도 시 첫 폴링의 `_herdr agent list`가 빈 에이전트 목록(`False`)을 반환해 죽은 마커를 즉시 정리하고 대기 없이 통과합니다. + +--- + +## 2. 전체 체크리스트 (1~8) 점검 요약 + +| # | 점검 항목 | 결과 | 근거 파일 및 라인 | +|---|---|---|---| +| 1 | 완료 조건 1~8 및 테스트 커버리지 | **PASS** | `session_ids.py:116-297`, `id_order.py:1-382`, `tests/test_issue63_same_cwd_ids.py` (총 27개 테스트) | +| 2 | `#40` `autopin.capture` 재사용 (판정 단일화) | **PASS** | `session_ids.py:19,176,272` (`from lib_py.agents.autopin import OWN_KEYS, capture, claimed_elsewhere`), `lib.sh` 구 `capture_conversation_id` 제거 | +| 3 | `id_order.py` 잠금·만료·비차단 조건·`MAM_ID_ORDER_WAIT_S` | **PASS** | `fcntl.flock` (`id_order.py:293`), `wait_s` (`id_order.py:36-41`), `herdr_session_state` (`id_order.py:58-83`), `_read_and_prune_markers` (`id_order.py:180-235`) | +| 4 | `resume`: `--drop-stale-id`, 사전 거부(exit 1), 이미 실행 중 분기, `SKILL.md` | **PASS** | `resume_session.sh:132-170,243,270-276`, `update_yaml_resumed.sh:21,66-197`, `multi-agent-mux-resume/SKILL.md:59-83,165-171` | +| 5 | `stop`: kill 전 ID 판정, 죽은 herdr 미기록+이유 기록, purge 재검증 | **PASS** | `stop_session.sh:104-179,233-286,308-358`, `multi-agent-mux-stop/SKILL.md:38-55,98-115` | +| 6 | 삭제된 심볼 잔존 참조 없음 (`capture_conversation_id`, `CAPTURE_ID`) | **PASS** | `git grep` 결과 코드·문서·테스트 내 잔존 참조 0건 | +| 7 | macOS 호환성 (`/bin/bash 3.2.57`, `/usr/bin/python3 3.9.6`) | **PASS** | 수정된 셸 6개 `/bin/bash -n` 통과, Python 3.9 AST 파싱 및 모듈 import(`PY39_IMPORT_OK`) 통과 | +| 8 | `test_issue34_job_tree.py::test_t5b_a_*[False]` 관련성 여부 | **PASS** | 본 브랜치는 루프·잡 트리(`lib_py/loop`, `delegate-job`)를 전혀 수정하지 않으며, 해당 테스트는 본 변경과 무관함 | + +--- + +## 3. 실행한 검증 명령 및 결과 + +1. **정적 검사 (`git diff --check`, `/bin/bash -n`, `/usr/bin/python3` 3.9.6 import)**: + - Exit `0` (`PY39_IMPORT_OK`). +2. **Issue #63 `id_order` 및 이식성 테스트 + 실제 `herdr` 호출 감시(Spy) 검증**: + - `pytest tests/test_issue63_same_cwd_ids.py -k "test_id_order or test_c6_id_order_markers or test_portability" -v` + - 결과: **`8 passed`**, `SPY_LOG` 비어 있음 (`0` calls). +3. **Herdr 격리 및 Skill 블록 인벤토리 정합성 테스트**: + - `pytest tests/test_herdr_real_server_leak.py tests/test_issue42_skill_blocks.py -v` + - 결과: **`40 passed, 1 skipped in 1.52s`**. +4. **Negative Regression 검증**: + - Base(`dc886ec`): `test_issue63_same_cwd_ids.py` 12개 실패 확인. + - `ede9c62` 대비: `test_id_order_real_probe_against_mock_herdr_fails_closed`가 `protocol_mismatch`에서 `assert 0 == 1`로 실패하고 `HEAD`에서 통과함을 확인. + - `7cac6f6`(`HEAD~1`) 대비: `1614af5`의 동일 세션 이름 고아 마커 검증(`assert wait_same() == 1`)이 `HEAD~1`에서 `assert 0 == 1`로 실패하고 `1614af5`(`HEAD`)에서 통과함을 확인. + +--- + +## 4. 발견 사항 + +- 차단 결함(P0/P1) 및 비차단 결함(P2) 없음. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-grok-01/report-dee00bc0.identity.json b/.agents/reports/reviewer-grok-01/report-dee00bc0.identity.json new file mode 100644 index 0000000..8dbb2d3 --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-dee00bc0.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20261023T035928Z-2ccc02", + "base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630", + "head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14", + "tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188", + "review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14", + "reviewer": "reviewer-grok-01", + "job_id": "dee00bc0", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-10-23T05:16:58Z" +} \ No newline at end of file diff --git a/.agents/reports/reviewer-grok-01/report-dee00bc0.md b/.agents/reports/reviewer-grok-01/report-dee00bc0.md new file mode 100644 index 0000000..939c389 --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-dee00bc0.md @@ -0,0 +1,102 @@ +# Review dee00bc0 — PR #69 / Issue #63, fourth Mac pass + +PASS. Head `1614af5` keeps a dead-owner marker whose session name is the caller's own until herdr returns a parsed agent list that omits that name, or until the row is registered for the attempt. A live owner of that name is kept. An empty session name is still dropped. A failed lookup, `server_not_running` included, and a wait cap of 0 leave the marker in place. A normal create or resume whose `has-session` already sees the name returns before this wait. A retry whose probe says the session is gone returns on that probe. + +Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1` (`loop-20261023T035928Z-2ccc02`). Head `1614af5b673d7519d3a38f63e3636055592b4f14`, tree `f1af6fdd548ee5d6f6b81e2202833edfaca98188`. Base `dc886ecd6b38c01032a55a2d1c230c73362ae630` (main, v4.9.9) is an ancestor and is the control-plane HEAD. Thirteen commits, 14 files, +2234/−102. Against the previous review head `7cac6f6` the delta is 2 files, +42/−16: `.agents/skills/lib_py/agents/id_order.py` and `tests/test_issue63_same_cwd_ids.py`. No uncommitted changes. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree `mam-wt-pr69` was not run. + +The new commit is `1614af5` `fix(id_order): a dead-owner marker on the caller's own session follows the orphan rules`. It closes the residual recorded on `7cac6f6`, where a dead owner of the waiter's own session name was dropped before herdr was asked. `8f64174` and `7cac6f6` stay underneath it. HEAD still prunes only on an authoritative gone, still treats `server_not_running` as unknown, and still asks herdr nothing at cap 0. The same-name case now uses that branch. + +Host tools: control-plane venv Python 3.9.6, sqlite 3.51.0, `/bin/bash` 3.2.57(1)-release. The real agent databases were not opened. + +## Same-name orphan + +`_read_and_prune_markers` no longer takes the waiter session name (`id_order.py:183-186`, call at `308-309`). A dead owner's marker (`id_order.py:217-229`): + +- A missing owner pid, or an owner pid equal to this waiter, drops the marker (`219-220`). +- A live owner keeps it, including a live attempt on the caller's session (`221-222`). +- An empty session name drops it (`223-224`). +- A row registered for the attempt drops it without a probe. Registered means status `running` and `herdr_session_epoch` greater than the marker's `prev_epoch` (`177-180`, `225-226`). +- Otherwise the probe runs. `drop` is true only when it returns false. True and `None` keep the marker, and `_orphan` is `"alive"` or `"unknown"` (`227-229`). A marker left by an earlier attempt on the caller's own session name takes this path. + +`herdr_session_state` (`id_order.py:58-86`) is unchanged in this commit. False is only a successful, parsed `_herdr agent list` whose agents omit the session name and `sanitize_herdr_agent_name` of that name (`84-86`). A non-zero exit, a timeout, an `OSError`, or JSON that has no `result.agents` list returns `None` (`75-83`). `timeout <= 0` returns `None` before any subprocess (`64-65`). Measured on this snapshot: `herdr_session_state(..., 0)` and `herdr_session_state(..., -1)` are both `None`. `wait_s({})` is 600, `wait_s` of `"0"` is 0, and `wait_s` of `"abc"` is 600 (`36-41`). + +The child environment sets `MAM_NO_AUTOSTART=1` and `HERDR_SESSION_NAME` to the marker's server, or `default` when that field is empty (`67`). The shim skips autostart when that variable is `1` (`lib.sh:203-208`). `bounded_probe` passes the time left on the deadline (`287-291`). The call sits inside `fcntl.flock(LOCK_EX)`. macOS has no `flock(1)`. The ordering tests passed with `fcntl`. + +`blockers` includes a kept marker of the caller's own session when that row is absent (`133-147`). The waiter then holds until the marker is pruned or the cap expires (`325-347`). On cap expiry the message uses the last definite answer from this wait (`333-344`). An unknown orphan at cap 0 adds `MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr`. + +## Ordinary create, resume, and retry + +Create calls `has-session` and exits 3 when the name is already present (`create_session.sh:168-170`). The id-order wait is later (`287`). Resume sets `HERDR_ALIVE` from `has-session` (`resume_session.sh:125-128`) and, when the session is alive, updates the row and exits (`158-169`) before the wait (`243`). The mock `has-session` exits 0 when the agent name is in its state (`conftest.py:1345-1355`). A retry that herdr still lists under that name therefore stops at `has-session` and does not enter the 600-second cap. + +The wait itself returns 0 on the first lock hold when `blockers` is empty (`310-323`). That is a create or resume with no live same-cwd marker and no running row that still lacks an id. The default 600 is the cap for a marker that stays, and the function does not sleep it out on an empty blocker list. + +When the caller has already passed `has-session` and a dead-owner marker of the same name remains: + +- A parsed list that omits the name removes the marker and returns 0 on that pass (`228-229`, then `310-323`). The real-probe test does this with cap 2 on the marker's own session name and checks that the replacement marker's owner pid is the waiter (`test_issue63_same_cwd_ids.py:1315-1317`). +- A parsed list that contains the name keeps the dead owner's pid and returns 1 inside the cap (`1290-1292`). The unit test at `wait_s_val=0` with an injected `pane_state` prints `its herdr session is alive` and `kill-session -t same-agy` (`1133-1140`). +- `None`, which covers a failed lookup, `server_not_running`, and a real cap of 0, keeps the marker and the waiter uses the cap. That is the same rule `7cac6f6` already applies to every other orphan. The real-probe cap-0 assertion is on the other session name in the same test (`1306-1310`); the same-name branch calls the same `herdr_session_state`. + +The head suite finished in 1301.73 s, in line with the previous pass on `7cac6f6` (1263.90 s). A common create or resume that now slept out 600 seconds would have stretched that run. It did not. + +## Tests for this commit + +`no_herdr_probe` (`test_issue63_same_cwd_ids.py:32-36`) makes an in-process `herdr_session_state` return false unless the test passes `pane_state`. The shell tests start a child, and that child runs the real function. + +`test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive` (`1047`) still covers another session's orphan: alive keeps and prints the kill line, unknown keeps, a registered running row drops the marker with an empty probe list, and a false answer admits the next session. The same-name block (`1120-1148`) keeps a live owner's pid, keeps a dead owner's pid while the injected probe returns true or `None`, and on false replaces the owner pid with the waiter's pid. + +`test_id_order_real_probe_against_mock_herdr_fails_closed` (`1250`) sources the sandbox `lib.sh` and calls `mam_id_order_wait`. A listed agent returns 1 for both the other session name and the marker's own name, and the dead owner's pid stays (`1287-1292`). `protocol_mismatch` and `server_not_running` return 1 with `herdr could not tell whether its session is gone` (`1294-1304`). The mock exits 1 for `force_error_code` before the subcommand (`conftest.py:486-488`). Cap 0 returns 1 with `MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr` and keeps the marker (`1306-1310`). An unlisted agent returns 0 for the other name, and a following wait on the marker's own name returns 0 with a new owner pid (`1312-1317`). The mock `agent list` prints `{"result": {"agents": [...]}}` with each agent's `name` (`conftest.py:794-806`). A parsed list that contains the name is what prints `its herdr session is alive`. + +`mock_herdr` prepends its bin (`conftest.py:1411`). These shell tests passed in the head run below. The unlisted own-name case returning 0, and the listed own-name case returning 1, are the evidence the probe reached the mock. + +`test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create` (`1160`) and `test_id_order_herdr_probes_stay_within_the_wait_cap` (`1207`) are unchanged by this commit and passed. The create test uses a different session name and cap 3. The cap test's cap 0 finishes with every budget `<= 0` and both markers still on disk. + +## Rest of base..head + +The twelve commits through `7cac6f6` are the tree reviewed in job 737239ab. This commit does not change `session_ids.py`, `lib.sh`, the stop, resume, or create scripts, or the skill exit tables. `session_ids.py` still uses `#40`'s `capture` and `claimed_elsewhere`. Stop still decides the id before kill. Resume still resolves the id and refuses a claimed id before spawn. `git grep` for `capture_conversation_id` and `CAPTURE_ID`, excluding `.agents/reports` and `IMPROVEMENTS.md`, finds no caller. The diff names no loop or job-tree file, so the author's one-off `tests/test_issue34_job_tree.py::test_t5b_a_*[False]` failure is outside this change. `/bin/bash -n` accepts the snapshot `lib.sh`. Python 3.9.6 imports `id_order`. The annotations sit behind `from __future__ import annotations`. + +## Results + +Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamdee-dee00bc0-pytest` (head) and `/tmp/mamdee-dee00bc0-base-pytest` (base). Neither path contains `mam-review` or `shim`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_*`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `AGENT_SESSIONS_YAML`, the four `XDG_*` homes, and `AGENT_PYTHON_BIN` were dropped. + +Head, cwd the snapshot: `tests/test_issue63_same_cwd_ids.py`, `test_issue40_auto_pin.py`, `test_issue64_ro_connect.py`, `test_issue10_13_purge.py`, `test_issue31_purge_residual.py`, `test_issue42_skill_blocks.py`, `test_tier1_unit.py`, `test_tier2_component.py`, `test_uuid_target.py`, `test_issue24_resume_dialogs.py`. **359 passed, 1 skipped** in 1301.73 s. Exit 0. The log closed at 2026-10-23 14:09:03. `.claude/skills` is absent in this snapshot, and `test_claude_skills_link_resolves_to_agents_skills` skips in that case (`test_issue42_skill_blocks.py:266`). That file is outside this delta. The same-name assertions are inside the two existing probe tests, so the pass count matches the previous head. + +Base, this turn: a throwaway `git clone --shared` checked out at `dc886ecd6b38c01032a55a2d1c230c73362ae630`, with only `tests/test_issue63_same_cwd_ids.py` copied from head. `id_order.py` is absent there. **27 errors** in 0.11 s. Exit 1. Each error is `ImportError: cannot import name 'id_order' from 'lib_py.agents'` at setup of `no_herdr_probe`, including `test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`, and `test_c4`. The snapshot was not checked out. Snapshot porcelain stayed empty. + +The behavioral base result for this same base, on the test file from before that fixture imported `id_order`, remains the one recorded by job ba3eeb7b: **22 failed, 1 passed** in 181.15 s. The pass was `test_c4_pinned_post_discovered_and_preassigned_agents_unchanged`. On that run the second stop row received A's id, purge printed `purged:`, the unpinned resume carried A's id, and cap 0 still spawned. Those tests passed on this head inside the 359. + +1. Stop order A→B and B→A, including herdr already dead, records only a verified own id. `test_c1_stop_same_cwd_agy_sessions_records_only_verified_owner` passed on head. +2. Purge of B leaves A's files. `test_c2_purge_never_deletes_another_rows_conversation` passed on head. +3. Resume of an unpinned B stays off A's conversation, and the already-running branch keeps the epoch. `test_c3_resume_unpinned_second_agy_row_starts_fresh_and_running_branch_keeps_epoch` passed on head. +4. A row that already has an own id, and claude/grok, stay on the previous path. `test_c4_pinned_post_discovered_and_preassigned_agents_unchanged` passed on head, along with `test_issue40_auto_pin.py`, `test_issue64_ro_connect.py`, and `test_issue10_13_purge.py`. +5. A same-cwd create waits while another post-discovered row of the same agent has no id. `test_c6_create_waits_or_refuses_while_same_cwd_post_discovered_row_has_no_id` passed on head. The orphan tests cover a dead owner, including one on the caller's own session name, whose herdr session is alive, unknown, or gone. +6. Resume refuses before spawn when the resolved id belongs to another row. `test_c7_resume_refuses_when_recorded_id_is_claimed_by_another_row` passed on head. + +## Blocking defects + +None. + +## Non-blocking + +- While herdr keeps failing the lookup, the orphan marker stays, and every later create or resume that reaches the wait — a retry of the marker's own session name included — waits out `MAM_ID_ORDER_WAIT_S` again (`id_order.py:75-76`, `227-229`). The wait is bounded. Create and resume still return at `has-session` when that command already sees the name (`create_session.sh:168-170`, `resume_session.sh:158-169`). The cap message names `herdr kill-session`. +- The probe holds the id-order lock until it returns. The budget is the time left on the deadline, and at most 30 seconds (`id_order.py:73`, `287-309`). +- An empty `herdr_server` is probed on the `default` server (`id_order.py:67`). Create and resume export `HERDR_SESSION_NAME` before they write a new marker (`create_session.sh:185-186`, `resume_session.sh:111-114`). The cap line prints `default` for an empty server and leaves the server and session unquoted (`id_order.py:335-344`). +- A successful agent list that is briefly empty while a pane is still registering would prune the marker. The tests cover a list that already contains the name, and a list that does not. +- One remaining same-type row with herdr dead still uses `find_workspace_uuid` (`session_ids.py:274-275`). +- A running row with an empty own id and a missing pane pid blocks until the cap (`id_order.py:128-131`). The unit test asserts that return of 1 (`test_issue63_same_cwd_ids.py:1150-1154`). +- A marker whose owner pid has been reused by an unrelated live process stays until that process exits (`id_order.py:221-222`). +- With herdr alive, a recorded id that fails revalidation is still returned (`session_ids.py:240-241`). +- An unparsable `MAM_ID_ORDER_WAIT_S` becomes 600 (`id_order.py:36-41`). +- `stop_session.sh:220` uses `eval "$(mam_session_ids stop ...)" || true`. A normal decision returns 0. A crashed helper leaves the captured fields empty and the stop continues. +- `--no-onboard` waits out the cap. Presence-lock id discovery is not in this PR. Both are the deferrals named by the issue and the PR body. +- `resolve_session_id.sh:4-5` still describes an empty line and exit 0. The script calls `mam_session_ids` under `set -euo pipefail` (line 10). The resume skill exit table documents exit 1 when another row claims the id. +- Read-only connect residuals from #64 (immutable race, `mode=ro` sidecar rewrite, purge DELETE staying read-write) are outside this diff. `_ro_connect` is unchanged. + +## Host check + +No process has a first command token of `opencode` or a path ending in `/opencode`. `lsof` on `~/.local/share/opencode/opencode.db` exited 1. That database was not opened. Its mtime is 1792641027 and its size is 36712448. `opencode.log` is absent. `~/.config/opencode/service.json` mtime is 1792246838 and its size is 64. `~/.local/state/opencode/service.json` is absent. The herdr config mtime is 1784456394. `~/.local/state/mam` mtime is 1791810241. `~/.local/state/mam/id-order` is absent. These stats match the check taken before the suite. + +Eight herdr processes were running immediately before the suite. After the suite log closed at 2026-10-23 14:09:03, the 14:09:37 check found seven. Two pids from the before-set were gone: one `/opt/homebrew/bin/herdr` whose session argument was `multi-agent-mux` (about 40 seconds old at suite start) and one whose session argument was `mam-pylecturenotes` (about 3 seconds old at suite start). The new process is `/opt/homebrew/bin/herdr` with `--session multi-agent-mux`, elapsed time about 54 seconds at 14:09:37, so it started near 14:08:43, during the last half-minute of the suite. No herdr command line contained the review basetemp, the snapshot path, or the test session names `probe-ws` and `orphan-ws`. This review did not start or stop a herdr server. + +After this report file was written, the same check was repeated at 2026-10-23 14:11:38. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty at `dc886ecd6b38c01032a55a2d1c230c73362ae630`. No process has a first command token of `opencode` or a path ending in `/opencode`, and `lsof` still reports no database holder. The database mtime is still 1792641027 and its size is still 36712448. `opencode.log` is still absent. The config service file mtime is still 1792246838. The state service file is still absent. The herdr config mtime is still 1784456394. `~/.local/state/mam` mtime is still 1791810241, and `id-order` is still absent. Seven herdr processes were present. The pid set differs from the 14:09:37 set by one homebrew herdr process whose arguments include `--session multi-agent-mux`; its elapsed time was about 54 seconds, so that process had turned over again. No herdr command line contained the review basetemp, the snapshot path, or `probe-ws` or `orphan-ws`. This review did not start or stop it. + +[VERDICT: PASS] diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index d107fbe..62e33c2 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -29,7 +29,7 @@ export WORKSPACE_ROOT # Framework semantic version. Single runtime source of truth; kept in lockstep # with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py. # NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable. -MAM_VERSION="4.9.9" +MAM_VERSION="4.10.0" export MAM_VERSION AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" diff --git a/.agents/skills/lib_py/loop/eager.py b/.agents/skills/lib_py/loop/eager.py index ea3ccbd..8f64ce9 100644 --- a/.agents/skills/lib_py/loop/eager.py +++ b/.agents/skills/lib_py/loop/eager.py @@ -13,7 +13,7 @@ import sys from typing import List, Sequence # Framework version locked with framework constants -FRAMEWORK_VERSION = "4.9.9" +FRAMEWORK_VERSION = "4.10.0" CORE_MODULES: Sequence[str] = ( # Standard library modules diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 5a8c659..2a1bb65 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-create description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace." -version: 4.9.9 +version: 4.10.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md index df70c54..52b8e80 100644 --- a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md +++ b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-delegate-job description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer." -version: 4.9.9 +version: 4.10.0 author: godopu license: MIT platforms: [linux, macos, windows] diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 5609025..4735753 100644 --- a/.agents/skills/multi-agent-mux-loop/SKILL.md +++ b/.agents/skills/multi-agent-mux-loop/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-loop description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached." -version: 4.9.9 +version: 4.10.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index a287acc..f83de48 100644 --- a/.agents/skills/multi-agent-mux-monitor/SKILL.md +++ b/.agents/skills/multi-agent-mux-monitor/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-monitor description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted." -version: 4.9.9 +version: 4.10.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md index 03c82c1..caa8c11 100644 --- a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md +++ b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-orc-onboard description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture." -version: 4.9.9 +version: 4.10.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 8707f99..3690584 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-resume description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r ` or `agy --conversation ` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk." -version: 4.9.9 +version: 4.10.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-status/SKILL.md b/.agents/skills/multi-agent-mux-status/SKILL.md index 40d0bfe..1679891 100644 --- a/.agents/skills/multi-agent-mux-status/SKILL.md +++ b/.agents/skills/multi-agent-mux-status/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-status description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop." -version: 4.9.9 +version: 4.10.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index baa1397..bd7ab73 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-stop description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start." -version: 4.9.9 +version: 4.10.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/BOOTSTRAP.ko.md b/BOOTSTRAP.ko.md index ab13c5b..c728c7d 100644 --- a/BOOTSTRAP.ko.md +++ b/BOOTSTRAP.ko.md @@ -2,7 +2,7 @@ 본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다. -[![Version: v4.9.9](https://img.shields.io/badge/version-v4.9.9-blue.svg)](VERSIONS.md) +[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md) 새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다. diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 4766b77..0b95cc9 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -2,7 +2,7 @@ This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly. -[![Version: v4.9.9](https://img.shields.io/badge/version-v4.9.9-blue.svg)](VERSIONS.md) +[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md) A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment. diff --git a/MESSAGING.md b/MESSAGING.md index c284dd7..862a439 100644 --- a/MESSAGING.md +++ b/MESSAGING.md @@ -1,6 +1,6 @@ # Messaging System Technical Analysis & Architecture Report -Applies to: MAM v4.9.9 +Applies to: MAM v4.10.0 This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations. diff --git a/README.ko.md b/README.ko.md index 3b9e100..2b33239 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,7 +2,7 @@ [herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다. -[![Version: v4.9.9](https://img.shields.io/badge/version-v4.9.9-blue.svg)](VERSIONS.md) +[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md) --- diff --git a/README.md b/README.md index 19e284b..0a70448 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change. -[![Version: v4.9.9](https://img.shields.io/badge/version-v4.9.9-blue.svg)](VERSIONS.md) +[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md) --- diff --git a/VERSIONS.md b/VERSIONS.md index 733ce71..9025eb2 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -6,8 +6,8 @@ ## 📌 현재 버전 개요 (Current Release) -- **프레임워크 버전**: `v4.9.9` -- **최신 릴리스 일시**: 2026-10-21 (KST) +- **프레임워크 버전**: `v4.10.0` +- **최신 릴리스 일시**: 2026-10-23 (KST) - **기준 브랜치**: `main` - **핵심 아키텍처**: - **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입. @@ -24,23 +24,55 @@ ## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix) -모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.9`로 동기화되어 배포됩니다. +모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.10.0`로 동기화되어 배포됩니다. | 스킬명 | 버전 | 역할 및 주요 책임 | 상태 | | :--- | :---: | :--- | :---: | -| **`multi-agent-mux-create`** | `4.9.9` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 | -| **`multi-agent-mux-stop`** | `4.9.9` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 | -| **`multi-agent-mux-resume`** | `4.9.9` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 | -| **`multi-agent-mux-status`** | `4.9.9` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 | -| **`multi-agent-mux-monitor`** | `4.9.9` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 | -| **`multi-agent-mux-delegate-job`** | `4.9.9` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 | -| **`multi-agent-mux-loop`** | `4.9.9` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 | -| **`multi-agent-mux-orc-onboard`** | `4.9.9` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 | +| **`multi-agent-mux-create`** | `4.10.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 | +| **`multi-agent-mux-stop`** | `4.10.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 | +| **`multi-agent-mux-resume`** | `4.10.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 | +| **`multi-agent-mux-status`** | `4.10.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 | +| **`multi-agent-mux-monitor`** | `4.10.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 | +| **`multi-agent-mux-delegate-job`** | `4.10.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 | +| **`multi-agent-mux-loop`** | `4.10.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 | +| **`multi-agent-mux-orc-onboard`** | `4.10.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 | --- ## 📋 버전별 상세 변경 내역 (Changelog) +### 🚀 `v4.10.0` — 같은 폴더의 같은 종류 에이전트가 서로의 대화 ID 를 쓰지 않는다 (Issue #63) (2026-10-23) + +> **마이너 (MINOR Release)**: 같은 폴더에 ID 가 나중에 생기는 같은 종류 에이전트(agy·codex·hermes·opencode)가 둘 있으면, stop·purge·resume 이 `find_workspace_uuid` 의 추측으로 다른 행의 대화 ID 를 기록하거나 지우거나 이어받을 수 있었다. 이제 세 경로가 #40 의 판정을 같이 쓰고, 같은 폴더의 생성 순서를 정하는 새 대기 동작(`MAM_ID_ORDER_WAIT_S`)과 resume 의 새 거부(exit 1)가 추가되어 MINOR 로 올린다. PR #69. + +#### 🌟 주요 개선 사항 +1. **ID 판정 (`lib_py/agents/session_ids.py`, 새 파일)**: stop·purge·resume 이 같은 판정을 쓴다. 행의 own id 가 우선이고, 없으면 #40 의 `autopin.capture` 를 쓴다. 다른 행이 기록한 ID 는 쓰지도 지우지도 않는다. claude·grok 은 그대로다. +2. **stop·purge**: + - stop 은 프로세스를 끄기 전에 ID 를 정한다. herdr 가 이미 죽은 행은 확인할 수 없으므로 ID 를 기록하지 않고 이유를 남긴다. + - purge 는 own id 이거나 확인을 통과한 ID 만 지우고, 레지스트리를 쓰는 시점에 다른 행이 기록했는지 다시 확인한다. +3. **resume**: + - own id 가 없고 같은 폴더에 같은 종류 행이 둘 이상이면 추측하지 않고 새 대화로 띄운다. + - 해결한 ID 를 다른 행이 이미 기록했으면 띄우기 전에 거부한다(exit 1). + - 기록된 ID 가 더 이상 유효하지 않으면 새 대화로 띄우고 그 ID 를 지운다(`update_yaml_resumed.sh --drop-stale-id`). +4. **생성 순서 (`lib_py/agents/id_order.py`, 새 파일)**: + - 같은 폴더에서 같은 종류 에이전트가 생성 중이거나 ID 없이 실행 중이면, 새 create·resume 은 그 ID 가 기록될 때까지 기다린다. 폴더·에이전트 단위 잠금(`fcntl`)과 "생성 중" 표시를 쓰고, 폴더는 실제 경로로 비교한다. + - 상한은 `MAM_ID_ORDER_WAIT_S`(기본 600 s, 0 이면 기다리지 않음)다. 넘으면 아무것도 띄우지 않고 기다린 세션 이름을 알리며 거부한다(exit 1). + - create·resume 이 띄운 뒤 행을 등록하기 전에 죽어 "생성 중" 표시만 남으면, 그 herdr 세션이 살아 있는 동안 계속 막는다. herdr 가 세션이 없다고 확실히 답하거나 그 시도의 행이 등록되면 푼다. 조회 실패는 "모름"으로 보고 막으며, 조회도 대기 상한 안에서만 한다(상한 0 이면 묻지 않음). +5. **정리**: `find_workspace_uuid()` 가 ID 를 반환하고 다른 행의 ID 를 후보에서 뺀다(`exclude_ids`). 쓰이지 않게 된 `capture_conversation_id`(lib.sh)와 `CAPTURE_ID`(stop)를 지웠다. + +#### ✅ 품질 검증 +- **회귀**: `tests/test_issue63_same_cwd_ids.py`(27개). 완료 조건의 회귀 테스트는 base(v4.9.9)에서 실패하고 head 에서 통과한다. +- **리뷰 (`--all-reviewer`, 작성자 PR 의 리뷰 전용 루프, 수정은 claude creator)**: codex 가 고아 "생성 중" 표시 처리에서 차단 결함을 회차마다 찾았고 ede9c62·8f64174·7cac6f6·1614af5 에서 고쳤다. 마지막 루프 1회차(@1614af5) **4/4 PASS**. 리포트는 `.agents/reports//` 에 있다. +- **전체 스위트 (macOS, Python 3.9.6, @1614af5)**: 2097 passed, 56 skipped, 1 failed. + - 실패는 `tests/test_issue50_dispatch_herdr.py::test_c11_…` 하나다. 이 브랜치는 위임 코드를 건드리지 않고, 단독으로 세 번 돌리면 매번 통과했다(시간 의존). + - 실제 `~/.claude`·`/private/tmp/claude-`·herdr 서버 pane 목록·opencode 의 data·state·config 에 이 실행이 남긴 것은 없었다. +- **작성자 확인 (Linux)**: 전체 스위트와 실제 agy 두 개 시험(같은 폴더 동시 create·stop·동시 resume·B purge 뒤 A 의 대화 파일 보존). +- **알려진 비차단**: + - herdr 조회가 계속 실패하면 고아 표시가 남아 이후 create·resume 이 매번 상한까지 기다린다. 거부 메시지가 `herdr kill-session` 명령을 알려 준다. + - 등록 중에 herdr 목록이 잠깐 비어 있으면 표시가 풀릴 수 있다. + - `--no-onboard` 로 만든 세션은 첫 ID 가 기록될 때까지 다음 create 가 상한까지 기다린다(#68). + - presence lock 으로 ID 를 찾는 것과, MAM 밖에서 사람이 같은 폴더에 띄운 에이전트는 범위 밖이다. + ### 🚀 `v4.9.9` — completed 를 확인한 쪽이 세션 ID 를 등록하고, agy 는 다른 작업 공간의 대화를 받지 않는다 (Issue #40) (2026-10-21) > **패치 (PATCH Release)**: 세션 ID 가 생성 뒤에야 생기는 에이전트(agy·opencode·codex·hermes)는 지금까지 모니터 데몬의 reconcile 이 찾아 등록했다. 데몬이 없는 워크트리 creator 는 등록되지 않았고, agy 의 drift C 는 호스트 공용 대화 폴더의 모든 `*.db` 를 후보로 보아 다른 작업 공간의 대화 ID 를 등록할 수 있었다. 이제 잡의 completed 를 확인한 쪽이 같은 판정으로 한 번 등록하고, reconcile 도 그 판정을 쓴다. -- 2.54.0