fix(agents): 같은 폴더의 같은 종류 에이전트가 서로의 대화 ID 를 쓰지 않는다 (Issue #63) #69
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user