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>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-23 07:39:43 +09:00
1 parent a907cc5294
commit ede9c62be3
3 files changed
+216 -14

No files matched your search

+2 -1
View File
@@ -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:]))
+74 -12
View File
@@ -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 "",
)