Compare commits

..
9 Commits
Author SHA1 Message Date
GodopuandClaude Opus 5.5 a907cc5294 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 <noreply@anthropic.com>
2026-10-21 23:53:10 +00:00
Godopu d115bf7156 fix(id_order): reserve startup when resume resolves an unrecorded uuid (Issue #63) 2026-10-21 19:48:44 +00:00
Godopu c747374aa6 fix(session_ids,id_order): optional --session resolution, fresh-marker retention, and canonical cwd matching (Issue #63) 2026-10-21 18:24:14 +00:00
Godopu 8c9a68cf1c docs(stop,resume,create): the lines that became wrong (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu e594d2b650 feat(create,resume): same-cwd ordering of post-discovered agents (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu fa93cc5622 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) 2026-10-21 16:28:34 +00:00
Godopu 3a2219454e fix(stop): record and purge only the row's own or a verified id; a dead herdr records none (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu b8a30d5b93 feat(agents): session_ids - the stop/purge/resume id decision (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu ead490bddc refactor(workspace_uuid): find_workspace_uuid() returns the id and skips exclude_ids; main prints it (Issue #63) 2026-10-21 16:28:34 +00:00
14 changed files with 1856 additions and 102 deletions

No files matched your search

+23 -11
View File
@@ -2024,18 +2024,30 @@ PYEOF
}
# ---------------------------------------------------------------------------
# capture_conversation_id <agent> <workdir> [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.
# mam_session_ids <stop|resume> [args...]
# Issue #63: stop/purge/resume conversation-id decision (lib_py.agents.session_ids).
# ---------------------------------------------------------------------------
capture_conversation_id() {
local agent="$1" workdir="$2" session_name="${3:-}"
find_workspace_uuid "$workdir" "$agent" "$session_name"
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
}
# ---------------------------------------------------------------------------
# mam_id_order_wait <workspace> <agent> <session> [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
}
# ---------------------------------------------------------------------------
+289
View File
@@ -0,0 +1,289 @@
"""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 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 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 == 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)
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 _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,
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(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) -> 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:
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
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())
+351
View File
@@ -0,0 +1,351 @@
"""Stop, purge, and resume conversation-id decisions (Issue #63).
Post-discovered agents (agy, codex, hermes, opencode) write their conversation
id to disk only after their first turn, and agy's workspace cache holds one id
per cwd. Stop, purge, and resume therefore use the row's own recorded id first
(never falling through to a workspace guess when a recorded id is present), and
otherwise use the #40 active-session capture when herdr is alive. Pre-assigned
agents (claude, grok) keep find_workspace_uuid with other rows' claimed ids
excluded.
"""
from __future__ import annotations
import argparse
import os
import shlex
import sys
from typing import Any, Dict, List, Optional
from lib_py.agents.autopin import OWN_KEYS, capture, claimed_elsewhere
from lib_py.agents.base import DiscoveryContext, POST_DISCOVERED
from lib_py.agents.registry import agent_of_row, get_adapter
from lib_py.paths import resolve_home
from lib_py.state import load_state_json
from lib_py.verify_session import verify_session_uuid
from lib_py.workspace_uuid import find_workspace_uuid
def canonical_cwd(path: str) -> str:
if not path:
return ""
try:
return os.path.realpath(path)
except Exception:
return os.path.abspath(path)
def own_id(row: Optional[Dict[str, Any]], agent: str) -> str:
adapter = get_adapter(agent)
if not adapter or not isinstance(row, dict):
return ""
val = row.get(adapter.own_key)
return str(val).strip() if val else ""
def claiming_rows(row: Dict[str, Any], uuid: str, state: Dict[str, Any]) -> List[str]:
if not uuid:
return []
me = row.get("name")
holders: List[str] = []
for other in state.get("herdr_sessions") or []:
if other is row or (me and other.get("name") == me):
continue
if any(other.get(k) == uuid for k in OWN_KEYS):
other_name = str(other.get("name") or "<unnamed>")
if other_name not in holders:
holders.append(other_name)
return holders
def same_type_rows(state: Dict[str, Any], cwd: str, agent: str) -> List[Dict[str, Any]]:
if not cwd or not agent:
return []
target_cwd = canonical_cwd(cwd)
out: List[Dict[str, Any]] = []
for s in state.get("herdr_sessions") or []:
if not isinstance(s, dict):
continue
s_cwd = (s.get("pane") or {}).get("cwd") or ""
if s_cwd and canonical_cwd(s_cwd) == target_cwd and agent_of_row(s) == agent:
out.append(s)
return out
def row_for_capture(row: Optional[Dict[str, Any]], pane_pid: Optional[int],
cwd: str = "", name: str = "", agent: str = "") -> Dict[str, Any]:
copy = dict(row) if isinstance(row, dict) else {}
if name and not copy.get("name"):
copy["name"] = name
if agent and not copy.get("agent"):
copy["agent"] = agent
pane = dict(copy.get("pane") or {})
if cwd and not pane.get("cwd"):
pane["cwd"] = cwd
if isinstance(pane_pid, int) and pane_pid > 0:
pane["pid"] = pane_pid
copy["pane"] = pane
return copy
def _find_row(state: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]:
if not name:
return None
for s in state.get("herdr_sessions") or []:
if isinstance(s, dict) and s.get("name") == name:
return s
return None
def _parse_pid(raw: Any) -> Optional[int]:
if isinstance(raw, int):
return raw if raw > 0 else None
s = str(raw or "").strip()
return int(s) if s.isdigit() and int(s) > 0 else None
def decide_stop(state: Dict[str, Any], name: str, agent: str, cwd: str,
herdr_alive: bool, pane_pid: Optional[int] = None,
purge: bool = False, home: Optional[str] = None,
claude_dir: Optional[str] = None) -> Dict[str, str]:
home_resolved = resolve_home(home)
claude_resolved = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR") or f"{home_resolved}/.claude/projects"
row = _find_row(state, name)
row_ref = row if row is not None else ({"name": name} if name else {})
claimed = set(claimed_elsewhere(row_ref, state))
adapter = get_adapter(agent)
own = own_id(row, agent)
if own:
if own in claimed:
others = ", ".join(claiming_rows(row_ref, own, state))
return {
"captured": "",
"status": "sibling-claimed",
"purge_uuid": "",
"note": f"{own} is also recorded by {others}; registry needs fixing",
}
if adapter and adapter.id_lifecycle == POST_DISCOVERED:
if verify_session_uuid(cwd, agent, own, row, home_dir=home_resolved,
claude_dir=claude_resolved, mode="revalidate"):
return {
"captured": own,
"status": "already-set",
"purge_uuid": own if purge else "",
"note": "",
}
ctx = DiscoveryContext(workspace=cwd, agent_name=agent, home_dir=home_resolved,
claude_dir=claude_resolved, row=row, mode="revalidate")
can_purge = bool(purge and adapter.has_artifacts(own, ctx))
return {
"captured": "",
"status": "unverified-own",
"purge_uuid": own if can_purge else "",
"note": f"recorded id {own} no longer verifies; kept as is",
}
if adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
found = find_workspace_uuid(cwd, agent, name, state, home_resolved, claude_resolved,
exclude_ids=claimed) if cwd else ""
if found and found in claimed:
others = ", ".join(claiming_rows(row_ref, found, state))
return {
"captured": "",
"status": "sibling-claimed",
"purge_uuid": "",
"note": f"{found} is also recorded by {others}; registry needs fixing",
}
return {
"captured": found,
"status": "resolved" if found else "no-candidate",
"purge_uuid": found if purge else "",
"note": "" if found else "nothing on disk yet",
}
if herdr_alive:
cap_row = row_for_capture(row, _parse_pid(pane_pid), cwd=cwd, name=name, agent=agent)
res = capture(cap_row, agent, state, home_dir=home_resolved)
if res.get("status") == "captured" and res.get("session_id"):
u = str(res["session_id"])
if u in claimed:
others = ", ".join(claiming_rows(row_ref, u, state))
return {
"captured": "",
"status": "sibling-claimed",
"purge_uuid": "",
"note": f"{u} is also recorded by {others}; registry needs fixing",
}
return {
"captured": u,
"status": "captured",
"purge_uuid": u if purge else "",
"note": "",
}
st = str(res.get("status") or "no-candidate")
reason = str(res.get("reason") or "")
return {
"captured": "",
"status": st,
"purge_uuid": "",
"note": f"{st}: {reason}" if reason else st,
}
return {
"captured": "",
"status": "herdr-dead",
"purge_uuid": "",
"note": "herdr session was already gone, so the pane that owns the conversation could not be checked; no id recorded",
}
def decide_resume(state: Dict[str, Any], name: str, agent: str, workspace: str,
herdr_alive: bool = False, pane_pid: Optional[int] = None,
home: Optional[str] = None,
claude_dir: Optional[str] = None) -> Dict[str, Any]:
home_resolved = resolve_home(home)
claude_resolved = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR") or f"{home_resolved}/.claude/projects"
row = _find_row(state, name)
adapter = get_adapter(agent)
if row is None and not name and workspace:
same = same_type_rows(state, workspace, agent)
if len(same) == 1:
row = same[0]
name = str(row.get("name") or "")
elif adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
creator = next(
(s for s in same if str(s.get("name") or "").endswith(f"-creator-{agent}")),
None,
)
if creator is not None:
row = creator
name = str(row.get("name") or "")
row_ref = row if row is not None else ({"name": name} if name else {})
claimed = set(claimed_elsewhere(row_ref, state))
own = own_id(row, agent)
if own:
if own in claimed:
others = ", ".join(claiming_rows(row_ref, own, state))
return {
"uuid": "",
"drop_stale_id": False,
"notes": [],
"error": (f"ERROR: {name}'s recorded id {own} is also recorded by {others}; "
"refusing to resume on a conversation another session claims (Issue #63). "
"One of these rows holds the wrong id - fix the registry before resuming."),
}
if adapter and adapter.id_lifecycle == POST_DISCOVERED:
if verify_session_uuid(workspace, agent, own, row, home_dir=home_resolved,
claude_dir=claude_resolved, mode="revalidate"):
return {"uuid": own, "drop_stale_id": False, "notes": [], "error": None}
if herdr_alive:
return {"uuid": own, "drop_stale_id": False, "notes": [], "error": None}
note = (f"NOTE: {name}'s recorded {agent} id {own} no longer verifies "
"(its conversation is missing or empty); starting a new conversation (Issue #63)")
return {"uuid": "", "drop_stale_id": True, "notes": [note], "error": None}
if adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
u = find_workspace_uuid(workspace, agent, name, state, home_resolved, claude_resolved,
exclude_ids=claimed)
if u and u in claimed:
others = ", ".join(claiming_rows(row_ref, u, state))
return {
"uuid": "",
"drop_stale_id": False,
"notes": [],
"error": (f"ERROR: {name or agent}'s resolved id {u} is also recorded by {others}; "
"refusing to resume on a conversation another session claims (Issue #63)."),
}
return {"uuid": u, "drop_stale_id": False, "notes": [], "error": None}
if herdr_alive:
cap_row = row_for_capture(row, _parse_pid(pane_pid), cwd=workspace, name=name, agent=agent)
res = capture(cap_row, agent, state, home_dir=home_resolved)
u = str(res["session_id"]) if res.get("status") == "captured" and res.get("session_id") else ""
else:
same = same_type_rows(state, workspace, agent)
n = len(same)
if name and all(r.get("name") != name for r in same):
n += 1
if n >= 2:
label = name or agent
note = (f"NOTE: {label} has no recorded {agent} id and {n} {agent} sessions share "
f"{workspace}; the cache cannot tell them apart, so it starts a new conversation (Issue #63)")
return {"uuid": "", "drop_stale_id": False, "notes": [note], "error": None}
u = find_workspace_uuid(workspace, agent, name, state, home_resolved, claude_resolved,
exclude_ids=claimed)
if u and u in claimed:
others = ", ".join(claiming_rows(row_ref, u, state))
return {
"uuid": "",
"drop_stale_id": False,
"notes": [],
"error": (f"ERROR: {name or agent}'s resolved id {u} is also recorded by {others}; "
"refusing to resume on a conversation another session claims (Issue #63)."),
}
return {"uuid": u, "drop_stale_id": False, "notes": [], "error": None}
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(prog="python -m lib_py.agents.session_ids")
sub = p.add_subparsers(dest="cmd", required=True)
s_stop = sub.add_parser("stop")
s_stop.add_argument("--session", required=True)
s_stop.add_argument("--agent", required=True)
s_stop.add_argument("--cwd", default="")
s_stop.add_argument("--herdr-alive", default="0")
s_stop.add_argument("--pane-pid", default="")
s_stop.add_argument("--purge", default="0")
s_res = sub.add_parser("resume")
s_res.add_argument("--session", default="")
s_res.add_argument("--agent", required=True)
s_res.add_argument("--workspace", required=True)
s_res.add_argument("--herdr-alive", default="0")
s_res.add_argument("--pane-pid", default="")
s_res.add_argument("--shell", action="store_true")
a = p.parse_args(argv)
state = load_state_json()
q = shlex.quote
if a.cmd == "stop":
res = decide_stop(
state=state,
name=a.session,
agent=a.agent,
cwd=a.cwd,
herdr_alive=(a.herdr_alive == "1"),
pane_pid=_parse_pid(a.pane_pid),
purge=(a.purge == "1"),
)
print(f"CAPTURED_UUID={q(res['captured'])}")
print(f"CAPTURE_STATUS={q(res['status'])}")
print(f"PURGE_UUID={q(res['purge_uuid'])}")
print(f"STOP_ID_NOTE={q(res['note'])}")
return 0
res = decide_resume(
state=state,
name=a.session,
agent=a.agent,
workspace=a.workspace,
herdr_alive=(a.herdr_alive == "1"),
pane_pid=_parse_pid(a.pane_pid),
)
for note in res.get("notes") or []:
print(note, file=sys.stderr)
if res.get("error"):
print(res["error"], file=sys.stderr)
return 1
if a.shell:
print(f"RESUME_UUID={q(res['uuid'])}")
print(f"DROP_STALE_ID={'1' if res.get('drop_stale_id') else '0'}")
else:
print(res["uuid"])
return 0
if __name__ == "__main__":
sys.exit(main())
+34 -23
View File
@@ -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 = {
@@ -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()
@@ -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 |
@@ -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 <status> <note>
+10 -10
View File
@@ -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
@@ -60,7 +60,7 @@ If both are empty → for Class A agents (`agy`, `hermes`, `opencode`, `codex`),
bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace <path> --agent <agent> --session <name>
```
`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 <server> agent attach <name>` (`<server>` = 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 <server> agent attach <name>` (`<server>` = the `--herdr-session` you passed, or the row's `herdr_session`).
| Option | Meaning |
|---|---|
@@ -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)
@@ -4,9 +4,9 @@
# bash resolve_session_id.sh --workspace <path> --agent <claude|agy|hermes|grok|opencode|codex>
# 출력: 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"
@@ -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
@@ -227,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"}'
@@ -252,9 +267,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)"
@@ -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}'
+2 -2
View File
@@ -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/<key>/<uuid>.jsonl` and `<uuid>/`, `file-history/<uuid>/`, `session-env/<uuid>/`, `jobs/<uuid[:8]>/` (when its `state.json` names the session), stale `sessions/<pid>.json` (+ that pid's `.key`) and `<tmp>/claude-<uid>/*/<uuid>/`, 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/<uuid>.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain/<uuid>/`, `annotations/<uuid>.pbtxt` and `presence/<uuid>.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/<key>/<uuid>.jsonl` and `<uuid>/`, `file-history/<uuid>/`, `session-env/<uuid>/`, `jobs/<uuid[:8]>/` (when its `state.json` names the session), stale `sessions/<pid>.json` (+ that pid's `.key`) and `<tmp>/claude-<uid>/*/<uuid>/`, 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/<uuid>.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain/<uuid>/`, `annotations/<uuid>.pbtxt` and `presence/<uuid>.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
@@ -5,8 +5,8 @@
# [--reason <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.
#
@@ -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
@@ -198,12 +197,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,17 +208,19 @@ 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
delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
@@ -304,7 +299,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 +321,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 +358,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
+5 -5
View File
@@ -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": {
File diff suppressed because it is too large. Load diff