From 3a2219454ef7f8598717735275143ab2f1310998 Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 21 Oct 2026 15:36:33 +0000 Subject: [PATCH] fix(stop): record and purge only the row's own or a verified id; a dead herdr records none (Issue #63) --- .../scripts/stop_session.sh | 57 ++++--- tests/test_issue63_same_cwd_ids.py | 141 ++++++++++++++++++ 2 files changed, 178 insertions(+), 20 deletions(-) diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 10f5fe8..67aec7a 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -5,8 +5,8 @@ # [--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. # @@ -198,12 +198,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,19 +209,25 @@ 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 + + +# (end of pre-kill id decision) + delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" # graceful 종료: send-keys 로 정상 종료 유도 → 폴백 체인 (SIGTERM → SIGKILL). @@ -304,7 +304,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 +326,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 +363,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 diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index 9dbdfc0..fe44f7d 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -248,3 +248,144 @@ def test_session_ids_own_id_priority_rules_unit(tmp_path): s_stale = decide_stop(state_stale, "ws-creator-agy", "agy", str(ws), False, purge=False, home=str(home)) assert s_stale["captured"] == "" and "no longer verifies; kept as is" in s_stale["note"] + +# --------------------------------------------------------------------------- Done Criteria 1 & 2 (stop & purge) + +def _fake_lsof_multi(bindir: Path, mapping: dict) -> None: + """Fake lsof that prints the pid from `mapping` ({uuid_substr: pid}) for any matching path.""" + cases = "\n".join(f' *{k}*) echo {v}; found=1 ;;' for k, v in mapping.items()) + exe = Path(bindir) / "lsof" + exe.write_text( + "#!/bin/sh\n" + "found=0\n" + 'for a in "$@"; do\n' + ' case "$a" in\n' + f"{cases}\n" + " esac\n" + "done\n" + '[ "$found" = "1" ] && exit 0\n' + "exit 1\n" + ) + exe.chmod(0o755) + + +def _mock_herdr_agent(state_file: Path, name: str, cwd: Path, pid: int, epoch: int, + agent: str = "agy", pane_id: str = "") -> None: + state = json.loads(Path(state_file).read_text()) + pid_str = pane_id or f"w1:p_{name}" + state.setdefault("agents", {})[name] = { + "pid": pid, + "cwd": str(cwd), + "command": agent, + "agent": agent, + "pane_id": pid_str, + "workspace_id": "w1", + "created": epoch, + "buffer": f"Antigravity {cwd}\n>", + } + Path(state_file).write_text(json.dumps(state)) + + +def _write_rows(sandbox: Path, rows: list) -> Path: + lib = sandbox / ".agents" / "skills" / "lib.sh" + yaml_path = sandbox / ".mam" / "agent-sessions.yaml" + mutation = f"d['herdr_sessions'] = {repr(rows)}\n" + res = subprocess.run( + ["bash", "-c", f'source "{lib}" && atomic_dump_yaml "{yaml_path}"'], + input=mutation, capture_output=True, text=True, cwd=str(sandbox), timeout=60, + ) + assert res.returncode == 0, res.stdout + res.stderr + return yaml_path + + +def _read_rows(sandbox: Path) -> dict: + yaml_path = sandbox / ".mam" / "agent-sessions.yaml" + data = yaml.safe_load(yaml_path.read_text()) or {} + return {r["name"]: r for r in data.get("herdr_sessions") or []} + + +def _run_stop(sandbox: Path, session: str, agent: str = "agy", purge: bool = False) -> subprocess.CompletedProcess: + script = sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + cmd = ["bash", str(script), "--session", session, "--agent", agent] + if purge: + cmd += ["--purge-conversation", "--yes"] + env = dict(os.environ, SKS_QUIESCENT_TRIES="0", SKS_DIALOG_TIMEOUT="0", SKS_BUSY_TIMEOUT="0") + return subprocess.run(cmd, capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=120) + + +@pytest.mark.parametrize("order", [("a_then_b", True), ("b_then_a", True), ("both_dead", False)]) +def test_c1_stop_same_cwd_agy_sessions_records_only_verified_owner(mam_sandbox, mock_herdr, order): + """Done Criterion 1: two agy sessions in one cwd; cache points to A's UA. + Stop A->B and B->A with herdr alive records UA on A and None on B; when + herdr is already dead for both, neither records an id and both leave a reason.""" + label, alive = order + ws = mam_sandbox / "same-ws" + ws.mkdir(parents=True, exist_ok=True) + now = time.time() + epoch = int(now) - 100 + _agy_full_artifacts(mam_sandbox, UA) + _agy_full_artifacts(mam_sandbox, UB) + _agy_cache(mam_sandbox, {ws: UA}) + + proc_a = subprocess.Popen(["sleep", "120"]) + proc_b = subprocess.Popen(["sleep", "120"]) + try: + _fake_lsof_multi(mam_sandbox / "bin", {UA: proc_a.pid, UB: proc_b.pid}) + name_a, name_b = "same-ws-creator-agy", "same-ws-reviewer-agy" + _write_rows(mam_sandbox, [ + _row(name_a, "agy", ws, epoch, pid=proc_a.pid), + _row(name_b, "agy", ws, epoch, pid=proc_b.pid), + ]) + if alive: + _mock_herdr_agent(mock_herdr, name_a, ws, proc_a.pid, epoch, pane_id="w1:p_a") + _mock_herdr_agent(mock_herdr, name_b, ws, proc_b.pid, epoch, pane_id="w1:p_b") + + seq = [name_a, name_b] if label != "b_then_a" else [name_b, name_a] + for sname in seq: + res = _run_stop(mam_sandbox, sname, "agy") + assert res.returncode == 0, res.stdout + res.stderr + + rows = _read_rows(mam_sandbox) + if alive: + assert rows[name_a]["agy_conversation_id_own"] == UA + assert rows[name_a]["resumable"] is True + assert rows[name_b]["agy_conversation_id_own"] is None + assert rows[name_b]["last_visible_status"].startswith("stop: ") + else: + assert rows[name_a]["agy_conversation_id_own"] is None + assert rows[name_b]["agy_conversation_id_own"] is None + assert "herdr session was already gone" in rows[name_a]["last_visible_status"] + assert "herdr session was already gone" in rows[name_b]["last_visible_status"] + finally: + for p in (proc_a, proc_b): + p.kill() + p.wait() + + +@pytest.mark.parametrize("broken_dup", [False, True]) +def test_c2_purge_never_deletes_another_rows_conversation(mam_sandbox, mock_herdr, broken_dup): + """Done Criterion 2: A is stopped with UA; B has no own id (or also has UA in a + broken registry) and herdr is dead. Purging B leaves A's conversation intact.""" + ws = mam_sandbox / "same-ws" + ws.mkdir(parents=True, exist_ok=True) + epoch = int(time.time()) - 100 + a_files = _agy_full_artifacts(mam_sandbox, UA) + _agy_cache(mam_sandbox, {ws: UA}) + + name_a, name_b = "same-ws-creator-agy", "same-ws-reviewer-agy" + row_a = _row(name_a, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA, + session_id_source="discovered", session_id_verified=True) + row_b = _row(name_b, "agy", ws, epoch, status="running", + agy_conversation_id_own=(UA if broken_dup else None)) + _write_rows(mam_sandbox, [row_a, row_b]) + + res = _run_stop(mam_sandbox, name_b, "agy", purge=True) + assert res.returncode == 0, res.stdout + res.stderr + assert "nothing purged" in res.stdout + for p in a_files: + assert p.exists(), f"A's artifact was deleted by B's purge: {p}" + rows = _read_rows(mam_sandbox) + assert name_b not in rows + assert rows[name_a]["agy_conversation_id_own"] == UA + +