diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh b/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh index 16b4978..3d9484d 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh @@ -4,9 +4,9 @@ # bash resolve_session_id.sh --workspace --agent # 출력: stdout 으로 UUID 한 줄 (없으면 빈 줄 + exit 0) # -# P0-C: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::find_workspace_uuid -# 가 워크스페이스 격리된 해결 경로(per-row own id -> 디스크 스캔 -> cwd 일치하는 -# cache)만 사용. 다른 워크스페이스의 UUID 를 절대 반환하지 않음. +# P0-C / Issue #63: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::mam_session_ids +# 가 워크스페이스 격리된 해결 경로(per-row own id -> #40 active capture / 디스크 스캔)만 사용. +# 다른 워크스페이스나 다른 행이 기록한 UUID 를 절대 반환하지 않음. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" @@ -22,13 +22,17 @@ EOF WORKSPACE="" AGENT="" SESSION_NAME="" +HERDR_ALIVE="0" +PANE_PID="" while [ $# -gt 0 ]; do case "$1" in - --workspace) WORKSPACE="$2"; shift 2 ;; - --agent) AGENT="$2"; shift 2 ;; - --session) SESSION_NAME="$2"; shift 2 ;; - -h|--help) usage; exit 0 ;; + --workspace) WORKSPACE="$2"; shift 2 ;; + --agent) AGENT="$2"; shift 2 ;; + --session) SESSION_NAME="$2"; shift 2 ;; + --herdr-alive) HERDR_ALIVE="$2"; shift 2 ;; + --pane-pid) PANE_PID="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac done @@ -40,4 +44,6 @@ case "$AGENT" in *) echo "ERROR: --agent must be claude, agy, hermes, grok, opencode, or codex" >&2; exit 2 ;; esac -find_workspace_uuid "$WORKSPACE" "$AGENT" "$SESSION_NAME" +mam_session_ids resume --session "$SESSION_NAME" --agent "$AGENT" \ + --workspace "$(mam_abs_workspace "$WORKSPACE")" \ + --herdr-alive "$HERDR_ALIVE" --pane-pid "$PANE_PID" diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 152ad82..6988234 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -107,9 +107,33 @@ if [ "$cp_rc" -ne 0 ] || [ -z "$CP_JSON" ]; then CP_JSON="" fi -# 1. Resolve the session id -UUID=$(bash "$(dirname "${BASH_SOURCE[0]}")/resolve_session_id.sh" \ - --workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME") +if [ -n "$HERDR_SERVER_OPT" ]; then + export HERDR_SESSION_NAME="$HERDR_SERVER_OPT" +else + HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")" + export HERDR_SESSION_NAME +fi + +if [ -n "$HERDR_WORKSPACE_OPT" ]; then + export MAM_WS_LABEL="$HERDR_WORKSPACE_OPT" +else + export MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")" +fi + +HERDR_ALIVE=0 +PANE_PID_NOW="" +if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then + HERDR_ALIVE=1 + PANE_PID_NOW=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true) +fi + +# 1. Resolve the session id (Issue #63: own id first, then #40 capture when +# herdr is alive, or fresh spawn when 2+ same-type rows share the cwd) +RESUME_OUT=$(mam_session_ids resume --shell \ + --workspace "$(mam_abs_workspace "$WORKSPACE")" --agent "$AGENT" --session "$SESSION_NAME" \ + --herdr-alive "$HERDR_ALIVE" --pane-pid "$PANE_PID_NOW") +eval "$RESUME_OUT" +UUID="$RESUME_UUID" # ISSUE-3: agy/hermes/opencode (Class A) are spawned without a UUID - their # own conversation id is only assigned by the agent itself on its first turn. @@ -130,21 +154,8 @@ if [ -z "$UUID" ]; then esac fi -if [ -n "$HERDR_SERVER_OPT" ]; then - export HERDR_SESSION_NAME="$HERDR_SERVER_OPT" -else - HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")" - export HERDR_SESSION_NAME -fi - -if [ -n "$HERDR_WORKSPACE_OPT" ]; then - export MAM_WS_LABEL="$HERDR_WORKSPACE_OPT" -else - export MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")" -fi - # 2. If herdr is alive, print warning or attach. -if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then +if [ "$HERDR_ALIVE" = "1" ]; then if [ "${DRY_RUN:-0}" = "1" ]; then echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate" exit 0 @@ -153,7 +164,7 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then # Just update YAML to make sure it's set to running CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ - --herdr-session "$HERDR_SESSION_NAME" \ + --herdr-session "$HERDR_SESSION_NAME" --already-running \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} exit 0 fi @@ -252,9 +263,12 @@ handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT" sleep 2 # 5. Update agent-sessions.yaml: status running, last_visible_status +DROP_FLAG="" +[ "${DROP_STALE_ID:-0}" = "1" ] && DROP_FLAG="--drop-stale-id" CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ + ${DROP_FLAG:+$DROP_FLAG} \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} echo "Successfully resumed $SESSION_NAME ($AGENT)" diff --git a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh index 57c5739..7b6f270 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh @@ -27,6 +27,8 @@ ROLE="" HERDR_SERVER_OPT="" HERDR_WORKSPACE_OPT="" CMD_FULL_OPT="" +ALREADY_RUNNING=0 +DROP_STALE_ID=0 while [ $# -gt 0 ]; do case "$1" in @@ -38,6 +40,8 @@ while [ $# -gt 0 ]; do --herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;; --herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;; --cmd-full) CMD_FULL_OPT="$2"; shift 2 ;; + --already-running) ALREADY_RUNNING=1; shift ;; + --drop-stale-id) DROP_STALE_ID=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac @@ -110,6 +114,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ NOW_EPOCH="$NOW_EPOCH" TARGET_WORKSPACE="${WORKSPACE:-$WORKSPACE_ROOT}" ROLE="$ROLE" \ PANE_PID="$PANE_PID" CHILD_PID="$CHILD_PID" HERDR_SERVER_OPT_EXPLICIT="${HERDR_SERVER_OPT_EXPLICIT:-0}" \ MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \ + ALREADY_RUNNING="$ALREADY_RUNNING" DROP_STALE_ID="$DROP_STALE_ID" \ CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF' name = os.environ['SESSION_NAME'] uuid = os.environ['UUID'] @@ -173,7 +178,17 @@ if _cp_raw and not isinstance(target.get('control_plane'), dict): target['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')} except Exception: pass -if not uuid: +stale_dropped = '' +if not uuid and os.environ.get('DROP_STALE_ID') == '1': + from lib_py.agents.autopin import claimed_elsewhere + from lib_py.agents.registry import get_adapter + _adapter = get_adapter(agent) + _own_key = _adapter.own_key if _adapter else '' + _old = (target.get(_own_key) or '').strip() if _own_key else '' + if _old and _old not in set(claimed_elsewhere(target, d)): + target[_own_key] = None + stale_dropped = _old +if not uuid and os.environ.get('ALREADY_RUNNING') != '1': # ISSUE-3 (challenge c4b0a075): a Class A fresh-spawn resume re-arms # discovery from scratch - the resumed agent has not spoken yet, so any # on-disk conversation older than THIS resume must not be auto-pinned to @@ -196,6 +211,10 @@ target.pop('stop_reason', None) target.pop('resumable', None) if uuid: target['last_visible_status'] = f'resumed conversation {uuid} at {now}' +elif os.environ.get('ALREADY_RUNNING') == '1': + target['last_visible_status'] = f'already running; no id recorded at {now}' +elif stale_dropped: + target['last_visible_status'] = f'resumed (fresh spawn, recorded id {stale_dropped} no longer verified) at {now}' else: # ISSUE-3 Class A fresh-spawn resume: no conversation id existed yet. target['last_visible_status'] = f'resumed (fresh spawn, no prior turn) at {now}' diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index fe44f7d..1d6ece5 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -389,3 +389,227 @@ def test_c2_purge_never_deletes_another_rows_conversation(mam_sandbox, mock_herd assert rows[name_a]["agy_conversation_id_own"] == UA +# --------------------------------------------------------------------------- Done Criteria 3, 4, 7 & rev 2 (resume) + +def _run_resume(sandbox: Path, ws: Path, session: str, agent: str = "agy", + dry_run: bool = False) -> subprocess.CompletedProcess: + script = sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh" + cmd = ["bash", str(script), "--workspace", str(ws), "--agent", agent, "--session", session] + if dry_run: + cmd.append("--dry-run") + return subprocess.run(cmd, capture_output=True, text=True, cwd=str(sandbox), timeout=120) + + +def test_c3_resume_unpinned_second_agy_row_starts_fresh_and_running_branch_keeps_epoch( + mam_sandbox, mock_herdr, mock_agents +): + """Done Criterion 3: two stopped agy rows in one cwd (A=UA, B=None): + dry-run and real resume of B spawn without --conversation UA and emit NOTE; + when B's herdr is already alive without an id, resume does not record UA and + preserves B's herdr_session_epoch.""" + ws = mam_sandbox / "same-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch_a = int(time.time()) - 300 + epoch_b = int(time.time()) - 200 + _agy_full_artifacts(mam_sandbox, UA) + _agy_cache(mam_sandbox, {ws: UA}) + + name_a, name_b = "same-ws-creator-agy", "same-ws-reviewer-agy" + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch_a, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row(name_b, "agy", ws, epoch_b, status="stopped", agy_conversation_id_own=None), + ]) + + dry = _run_resume(mam_sandbox, ws, name_b, "agy", dry_run=True) + assert dry.returncode == 0, dry.stdout + dry.stderr + assert "--conversation" not in dry.stdout + assert f"NOTE: {name_b} has no recorded agy id and 2 agy sessions share" in dry.stderr + + real = _run_resume(mam_sandbox, ws, name_b, "agy", dry_run=False) + assert real.returncode == 0, real.stdout + real.stderr + rows = _read_rows(mam_sandbox) + assert rows[name_b]["status"] == "running" + assert rows[name_b]["agy_conversation_id_own"] is None + assert "--conversation" not in rows[name_b]["pane"]["cmd_full"] + + # Running branch: B is already alive in herdr without an own id; epoch must stay fixed. + fixed_epoch = 1700000123 + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch_a, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row(name_b, "agy", ws, fixed_epoch, status="running", agy_conversation_id_own=None), + ]) + _mock_herdr_agent(mock_herdr, name_b, ws, 9999, fixed_epoch, pane_id="w1:p_b") + + alive_res = _run_resume(mam_sandbox, ws, name_b, "agy", dry_run=False) + assert alive_res.returncode == 0, alive_res.stdout + alive_res.stderr + rows = _read_rows(mam_sandbox) + assert rows[name_b]["agy_conversation_id_own"] is None + assert rows[name_b]["herdr_session_epoch"] == fixed_epoch + assert rows[name_b]["last_visible_status"].startswith("already running; no id recorded at ") + + +def test_c4_pinned_post_discovered_and_preassigned_agents_unchanged(mam_sandbox, mock_herdr, mock_agents): + """Done Criterion 4: agy/opencode rows with own_id keep it on stop and resume with it; + claude/grok stop/resume and find_workspace_uuid behave as before.""" + ws = mam_sandbox / "c4-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + _agy_full_artifacts(mam_sandbox, UA) + _claude_transcript(mam_sandbox / ".claude" / "projects", ws, UB) + + # Seed opencode DB for ses_c4 + oc_db = mam_sandbox / ".local" / "share" / "opencode" / "opencode.db" + oc_db.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(oc_db)) + conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, time_created INTEGER)") + conn.execute("INSERT OR REPLACE INTO session_v2 VALUES (?, NULL, ?, ?)", ("ses_c4", str(ws), epoch * 1000)) + conn.commit() + conn.close() + + _write_rows(mam_sandbox, [ + _row("c4-creator-agy", "agy", ws, epoch, status="running", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row("c4-creator-opencode", "opencode", ws, epoch, status="running", opencode_session_id_own="ses_c4", + session_id_source="discovered", session_id_verified=True), + _row("c4-creator-claude", "claude", ws, epoch, status="running", claude_session_id_own=UB, + session_id_source="assigned", session_id_verified=True), + ]) + + for sname, agent in (("c4-creator-agy", "agy"), ("c4-creator-opencode", "opencode"), ("c4-creator-claude", "claude")): + st = _run_stop(mam_sandbox, sname, agent) + assert st.returncode == 0, st.stdout + st.stderr + + rows = _read_rows(mam_sandbox) + assert rows["c4-creator-agy"]["agy_conversation_id_own"] == UA and rows["c4-creator-agy"]["resumable"] is True + assert rows["c4-creator-opencode"]["opencode_session_id_own"] == "ses_c4" and rows["c4-creator-opencode"]["resumable"] is True + assert rows["c4-creator-claude"]["claude_session_id_own"] == UB and rows["c4-creator-claude"]["resumable"] is True + + r_agy = _run_resume(mam_sandbox, ws, "c4-creator-agy", "agy", dry_run=True) + assert r_agy.returncode == 0 and f"--conversation {UA}" in r_agy.stdout + r_oc = _run_resume(mam_sandbox, ws, "c4-creator-opencode", "opencode", dry_run=True) + assert r_oc.returncode == 0 and "--session ses_c4" in r_oc.stdout + r_cl = _run_resume(mam_sandbox, ws, "c4-creator-claude", "claude", dry_run=True) + assert r_cl.returncode == 0 and f"-r {UB}" in r_cl.stdout + + +@pytest.mark.parametrize("db_exists", [True, False]) +def test_c7_resume_refuses_when_recorded_id_is_claimed_by_another_row( + mam_sandbox, mock_herdr, mock_agents, db_exists +): + """Done Criterion 7 & rev 2 (A): when both A and B record UA (whether UA.db + exists on disk or is missing), resume B refuses with exit 1 before spawning, + names A on stderr, and emits no fresh-spawn NOTE.""" + ws = mam_sandbox / "c7-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + if db_exists: + _agy_full_artifacts(mam_sandbox, UA) + _agy_cache(mam_sandbox, {ws: UA}) + + name_a, name_b = "c7-ws-creator-agy", "c7-ws-reviewer-agy" + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row(name_b, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True), + ]) + before = _read_rows(mam_sandbox) + + res = _run_resume(mam_sandbox, ws, name_b, "agy", dry_run=False) + assert res.returncode == 1, res.stdout + res.stderr + assert name_a in res.stderr and UA in res.stderr + assert "NOTE:" not in res.stderr + state = json.loads(Path(mock_herdr).read_text()) + assert name_b not in state.get("agents", {}) + assert _read_rows(mam_sandbox) == before + + +def test_rev2_stale_own_id_single_row_never_overwrites_with_cache_and_clears_on_resume( + mam_sandbox, mock_herdr, mock_agents +): + """rev 2 (A)-3: single agy row with stale own_id UX (UX.db missing) while cache + and disk hold UC. Stop keeps UX; resume starts a fresh conversation without UC, + emits NOTE, and clears agy_conversation_id_own to None so #40 can pin later.""" + ws = mam_sandbox / "stale-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + _agy_full_artifacts(mam_sandbox, UC) + _agy_cache(mam_sandbox, {ws: UC}) + + name = "stale-ws-creator-agy" + _write_rows(mam_sandbox, [ + _row(name, "agy", ws, epoch, status="running", agy_conversation_id_own=UX, + session_id_source="discovered", session_id_verified=True), + ]) + + st = _run_stop(mam_sandbox, name, "agy") + assert st.returncode == 0, st.stdout + st.stderr + row_after_stop = _read_rows(mam_sandbox)[name] + assert row_after_stop["agy_conversation_id_own"] == UX + assert "no longer verifies; kept as is" in row_after_stop["last_visible_status"] + + res = _run_resume(mam_sandbox, ws, name, "agy", dry_run=False) + assert res.returncode == 0, res.stdout + res.stderr + assert f"NOTE: {name}'s recorded agy id {UX} no longer verifies" in res.stderr + row_after_resume = _read_rows(mam_sandbox)[name] + assert row_after_resume["agy_conversation_id_own"] is None + assert row_after_resume["session_id_source"] == "pending-discovery" + assert row_after_resume["session_id_verified"] is False + assert f"recorded id {UX} no longer verified" in row_after_resume["last_visible_status"] + assert UC not in row_after_resume["pane"]["cmd_full"] + + +def test_rev2_drop_stale_id_refuses_to_clear_id_claimed_by_another_row(mam_sandbox): + """rev 2: update_yaml_resumed.sh --drop-stale-id keeps own_id if another row also claims it.""" + ws = mam_sandbox / "dup-drop-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + name_a, name_b = "dup-creator-agy", "dup-reviewer-agy" + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA), + _row(name_b, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA), + ]) + upd = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh" + res = subprocess.run( + ["bash", str(upd), "--session", name_b, "--uuid", "", "--agent", "agy", + "--workspace", str(ws), "--drop-stale-id"], + capture_output=True, text=True, cwd=str(mam_sandbox), timeout=60, + ) + assert res.returncode == 0, res.stdout + res.stderr + assert _read_rows(mam_sandbox)[name_b]["agy_conversation_id_own"] == UA + + +def test_rev2_claude_unpinned_sibling_row_skips_creator_claimed_id(mam_sandbox, mock_herdr, mock_agents): + """rev 2 (B): ws-creator-claude is stopped with UA; ws-reviewer-claude has no + own_id (--wrapper row shape); disk has UA.jsonl and UB.jsonl. Resume and stop + of ws-reviewer-claude pick UB instead of creator's UA.""" + ws = mam_sandbox / "claude-sib-ws" + ws.mkdir(parents=True, exist_ok=True) + now = time.time() + epoch = int(now) - 100 + cdir = mam_sandbox / ".claude" / "projects" + _claude_transcript(cdir, ws, UA, mtime=now) + _claude_transcript(cdir, ws, UB, mtime=now - 10) + + name_a, name_b = "claude-sib-ws-creator-claude", "claude-sib-ws-reviewer-claude" + _write_rows(mam_sandbox, [ + _row(name_a, "claude", ws, epoch, status="stopped", claude_session_id_own=UA, + session_id_source="discovered", session_id_verified=True), + _row(name_b, "claude", ws, epoch, status="running", claude_session_id_own=None), + ]) + + dry = _run_resume(mam_sandbox, ws, name_b, "claude", dry_run=True) + assert dry.returncode == 0, dry.stdout + dry.stderr + + st = _run_stop(mam_sandbox, name_b, "claude") + assert st.returncode == 0, st.stdout + st.stderr + assert _read_rows(mam_sandbox)[name_b]["claude_session_id_own"] == UB + + dry_after = _run_resume(mam_sandbox, ws, name_b, "claude", dry_run=True) + assert dry_after.returncode == 0, dry_after.stdout + dry_after.stderr + assert f"-r {UB}" in dry_after.stdout + + +