Files
multi-agent-mux/.agents/skills/lib_py/agents/id_order.py
T
GodopuandClaude Opus 5.5 ede9c62be3 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 <noreply@anthropic.com>
2026-10-23 07:39:43 +09:00

352 lines
13 KiB
Python

"""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). 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
import argparse
import fcntl
import hashlib
import json
import os
import subprocess
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 canonical_cwd, claiming_rows, own_id
from lib_py.state import load_state_json
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 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("~")
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}")
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_cwd = canonical_cwd(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 canonical_cwd(s_cwd) != target_cwd:
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 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
row = rows_by_name.get(m_sess)
if row is None:
found.append(m_sess)
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)
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":
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
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))
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 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:
pass
continue
out.append(data)
return out
def write_marker(dpath: str, session: str, owner_pid: int, cwd: str, agent: 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] = {
"session": session,
"owner_pid": int(owner_pid),
"cwd": canonical_cwd(cwd),
"agent": agent,
"herdr_server": herdr_server,
"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(payload, 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,
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
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}
probe = pane_alive or herdr_session_alive
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, 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(
(
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,
herdr_server=herdr_server)
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,
)
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(
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="")
s_wait.add_argument("--herdr-server", 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 "",
herdr_server=a.herdr_server or "",
)
if __name__ == "__main__":
sys.exit(main())