Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a907cc5294 | ||
|
|
d115bf7156 | ||
|
|
c747374aa6 | ||
|
|
8c9a68cf1c | ||
|
|
e594d2b650 | ||
|
|
fa93cc5622 | ||
|
|
3a2219454e | ||
|
|
b8a30d5b93 | ||
|
|
ead490bddc |
No files matched your search
+23
-11
@@ -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
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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>
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user