fix(stop): record and purge only the row's own or a verified id; a dead herdr records none (Issue #63)

This commit is contained in:
Godopu committed 2026-10-21 16:28:34 +00:00
1 parent b8a30d5b93
commit 3a2219454e
2 files changed
+178 -20

No files matched your search

@@ -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.
#
@@ -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
+141
View File
@@ -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