From 7c40bf49308d5f455cc179eaf5cc975f579cd0de Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 00:32:57 +0900 Subject: [PATCH] fix(issue-9): translate only a structured server_not_running error in the shim's ls The ls translation searched stderr and the whole stdout for the substring server_not_running, so a successful `agent list` that merely carried the text (an agent named server_not_running-codex, or metadata) made the shim report "no server running". reconcile.sh reads that as a confirmed-empty server, and drift A would then mark every live row on it terminated (codex review of 0918917). - keep the RPC exit code and translate only a failed call whose error envelope (the whole stream or any line of it) has error.code == "server_not_running"; every other failure keeps the unknown path (nonzero exit, herdr_confirmed False) as before - tests: the real herdr 0.9 envelope, a successful list carrying the substring in a name and in metadata, other failures staying unknown, and reconcile keeping both live rows with no drift A; the new cases fail on 0918917 - delegate wrapper: log a non-pending status seen during the inject ack grace as "registry advanced" instead of attributing it to the agent - VERSIONS.md v4.4.2: describe the structured check and count 16 new tests Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe --- .agents/skills/lib.sh | 37 +++++- .../multi-agent-mux-delegate-job | 9 +- VERSIONS.md | 4 +- tests/test_issue14_delivery_ack.py | 2 +- tests/test_issue9_server_env.py | 120 ++++++++++++++++-- 5 files changed, 150 insertions(+), 22 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 85eb933..25d5bf7 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -1245,15 +1245,42 @@ except Exception: # running", tmux's phrasing) instead of an opaque parse failure, which # would flip herdr_confirmed=False and freeze drift detection for every # server on the next heartbeat. + # Only a FAILED call whose structured error envelope carries + # error.code == "server_not_running" means "down". A successful list can + # legitimately contain that text (an agent named server_not_running-codex), + # and reading it as "down" made reconcile terminate every live row on that + # server. Any other failure falls through to the unknown path below. _ls_err=$(mktemp 2>/dev/null || echo "/tmp/mam_ls_err_$$") - _ls_raw="$(_real_herdr agent list 2>"$_ls_err")" || true - if grep -q server_not_running "$_ls_err" 2>/dev/null \ - || printf '%s' "$_ls_raw" | grep -q server_not_running; then - rm -f "$_ls_err" 2>/dev/null || true + _ls_rc=0 + _ls_raw="$(_real_herdr agent list 2>"$_ls_err")" || _ls_rc=$? + _ls_down=0 + if [ "$_ls_rc" -ne 0 ]; then + _ls_down=$(printf '%s' "$_ls_raw" | MAM_LS_ERR_FILE="$_ls_err" python3 -c ' +import json, os, sys +out = sys.stdin.read() +try: + with open(os.environ["MAM_LS_ERR_FILE"]) as f: + err_text = f.read() +except Exception: + err_text = "" +down = 0 +for text in [out, err_text] + out.splitlines() + err_text.splitlines(): + try: + doc = json.loads(text) + except Exception: + continue + err = doc.get("error") if isinstance(doc, dict) else None + if isinstance(err, dict) and err.get("code") == "server_not_running": + down = 1 + break +print(down) +' 2>/dev/null || echo 0) + fi + rm -f "$_ls_err" 2>/dev/null || true + if [ "$_ls_down" = "1" ]; then echo "no server running (herdr session ${_sess:-default})" >&2 exit 1 fi - rm -f "$_ls_err" 2>/dev/null || true printf '%s\n' "$_ls_raw" | \ MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \ TZ=UTC LC_ALL=C python3 -c ' diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index 49d9751..4f4e445 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -569,15 +569,18 @@ _disk_status() { # reports `working`, yet publishes `started` within seconds. Closing such a job # as agent_blocked strands a live agent whose later `completed` the CAS table # then refuses. Before finalising, give the registry MAM_INJECT_ACK_GRACE_S -# (default 10 s) to show the agent's own acknowledgement: any status other than -# pending means the prompt was delivered. +# (default 10 s) to advance past pending. Any other status means the job moved +# on - normally the agent's own `started` - so the prompt is treated as +# delivered. The status is not pinned to this dispatch and may come from an +# external writer; the terminal handling after this still keeps such an +# error/cancelled as it is rather than turning it into success. _inject_acknowledged() { local job_id="$1" deadline st deadline=$(( $(date +%s) + ${MAM_INJECT_ACK_GRACE_S:-10} )) while :; do st="$(_disk_status "$job_id")" if [ -n "$st" ] && [ "$st" != "pending" ]; then - echo "inject: herdr could not confirm delivery, but job $job_id is already '$st' โ€” the agent acknowledged it; continuing." >&2 + echo "inject: herdr could not confirm delivery, but job $job_id already advanced to '$st' in the registry; treating the prompt as delivered and continuing." >&2 return 0 fi [ "$(date +%s)" -ge "$deadline" ] && return 1 diff --git a/VERSIONS.md b/VERSIONS.md index 3f3bb60..0cbd818 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -44,14 +44,14 @@ #### ๐ŸŒŸ ์ฃผ์š” ๊ฐœ์„  ์‚ฌํ•ญ 1. **herdr ์„œ๋ฒ„ ๊ธฐ๋™ env ๋งˆ์ปค denylist (Issue #9, `lib.sh` ์‰ผ)**: ์‰ผ์ด `herdr --session server` ๋ฅผ ๋ถ€ํŠธ์ŠคํŠธ๋žฉํ•  ๋•Œ ์„ธ์…˜ ์‹๋ณ„ ๋งˆ์ปค๋งŒ ์ œ๊ฑฐํ•œ ํ™˜๊ฒฝ์„ ๋„˜๊ธด๋‹ค โ€” `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_SESSION_ATTENDED`, `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_PID`, `CLAUDE_CODE_MESSAGING_TOKEN`, `CLAUDE_CODE_MESSAGING_SOCKET`, `CLAUDE_CODE_BRIDGE_SESSION_ID`, `ANTIGRAVITY_CONVERSATION_ID`, `HERMES_SESSION_ID`, `GROK_SESSION_ID`, `OPENCODE_SESSION_ID`. `CLAUDE_PROJECT_DIR`ยท`CLAUDE_CONFIG_DIR` ๋“ฑ ์‚ฌ์šฉ์ž ์„ค์ •์€ ๊ทธ๋Œ€๋กœ ์ƒ์†๋œ๋‹ค. ์ƒ์†๋œ `CLAUDE_CODE_CHILD_SESSION` ๋•Œ๋ฌธ์— ์„œ๋ธŒ์—์ด์ „ํŠธ claude ์˜ transcript ์ €์žฅ์ด ๊บผ์ ธ resume ์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋˜ ์ฆ์ƒ์„ ํ•ด์†Œ. 2. **์กฐํšŒ ๊ฒฝ๋กœ ์ž๋™ ๊ธฐ๋™ ๊ธˆ์ง€ `MAM_NO_AUTOSTART=1` (Issue #9)**: ์ด ๊ฐ’์ด ์„ค์ •๋˜๋ฉด ์‰ผ์€ ์„œ๋ฒ„๋ฅผ ๋„์šฐ์ง€ ์•Š๊ณ  ํ˜ธ์ถœ๋งŒ ์ „๋‹ฌํ•œ๋‹ค. `status.sh`ยท`reconcile.sh` ๊ฐ€ ์ด๋ฅผ export ํ•˜๋ฏ€๋กœ read-only `multi-agent-mux-status` ์กฐํšŒ๊ฐ€ ๋” ์ด์ƒ ์„œ๋ฒ„ ๋ฐ๋ชฌ์„ ๊ธฐ๋™ํ•˜์ง€ ์•Š๋Š”๋‹ค. -3. **์‰ผ `ls` ์˜ "no server running" ๋ฒˆ์—ญ (Issue #9)**: ์„œ๋ฒ„๊ฐ€ ๋‚ด๋ ค๊ฐ€ ์žˆ์œผ๋ฉด ์‰ผ์˜ `ls` ๊ฐ€ `server_not_running` ์„ `no server running (herdr session )` ์œผ๋กœ ๋ณด๊ณ ํ•œ๋‹ค. `reconcile.sh` ๋Š” ์ด๋ฅผ ๋นˆ ์„œ๋ฒ„๋กœ ํŒ์ •ํ•ด `herdr_confirmed` ๋ฅผ ์œ ์ง€ํ•œ๋‹ค(ํŒŒ์‹ฑ ์‹คํŒจ๋กœ ๋ชจ๋“  ์„œ๋ฒ„์˜ drift ๊ฐ์ง€๊ฐ€ ๋ฉˆ์ถ”๋Š” ํšŒ๊ท€ ๋ฐฉ์ง€). +3. **์‰ผ `ls` ์˜ "no server running" ๋ฒˆ์—ญ (Issue #9)**: ์„œ๋ฒ„๊ฐ€ ๋‚ด๋ ค๊ฐ€ ์žˆ์œผ๋ฉด ์‰ผ์˜ `ls` ๊ฐ€ `no server running (herdr session )` ์œผ๋กœ ๋ณด๊ณ ํ•˜๊ณ , `reconcile.sh` ๋Š” ์ด๋ฅผ ๋นˆ ์„œ๋ฒ„๋กœ ํŒ์ •ํ•ด `herdr_confirmed` ๋ฅผ ์œ ์ง€ํ•œ๋‹ค(ํŒŒ์‹ฑ ์‹คํŒจ๋กœ ๋ชจ๋“  ์„œ๋ฒ„์˜ drift ๊ฐ์ง€๊ฐ€ ๋ฉˆ์ถ”๋Š” ํšŒ๊ท€ ๋ฐฉ์ง€). ๋ฒˆ์—ญ์€ `agent list` ๊ฐ€ **์‹คํŒจ**ํ•˜๊ณ  ๊ทธ ์˜ค๋ฅ˜ ๋ด‰ํˆฌ์˜ `error.code` ๊ฐ€ ์ •ํ™•ํžˆ `server_not_running` ์ผ ๋•Œ๋งŒ ์ ์šฉํ•œ๋‹ค. ์„ฑ๊ณตํ•œ ๋ชฉ๋ก์— ๊ฐ™์€ ๋ฌธ์ž์—ด์ด ๋“ค์–ด ์žˆ๊ฑฐ๋‚˜(์˜ˆ: ์—์ด์ „ํŠธ ์ด๋ฆ„ `server_not_running-codex`) ๋‹ค๋ฅธ ์‹คํŒจ์ธ ๊ฒฝ์šฐ๋Š” ๋ฒˆ์—ญํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ, ์‚ด์•„ ์žˆ๋Š” ์„œ๋ฒ„๊ฐ€ ๋นˆ ์„œ๋ฒ„๋กœ ์˜คํŒ๋˜์–ด drift A ๋กœ ์ „ ์„ธ์…˜์ด ์ข…๋ฃŒ ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š๊ณ  ๊ทธ ๋ฐ–์˜ ์‹คํŒจ๋Š” ์ข…์ „๋Œ€๋กœ unknown(`herdr_confirmed=False`)์œผ๋กœ ๋‚จ๋Š”๋‹ค. 4. **accepted ์ œ์ถœ์˜ ์ „๋‹ฌ ํ™•์ธ `SKS_DELIVERY_GRACE_S` (Issue #14, `send_keys_safe`)**: `agent prompt --wait` ๊ฒฐ๊ณผ๊ฐ€ `timeout`/`agent_prompt_stalled` ์ด๋ฉด herdr ๊ฐ€ ์ž…๋ ฅ์„ ์ด๋ฏธ ์ˆ˜๋ฝํ•œ ๊ฒƒ์œผ๋กœ ๋ณด๊ณ , `SKS_DELIVERY_GRACE_S`(๊ธฐ๋ณธ 15์ดˆ) ๋™์•ˆ `agent_status` working, ์ œ์ถœ ์ „ ํ™”๋ฉด์— ์—†๋˜ busy ํ† ํฐ(`esc to interrupt`), ์ œ์ถœ ์ „ ์Šค๋ƒ…์ƒท ๋Œ€๋น„ pane ๋ณ€ํ™” ์ค‘ ํ•˜๋‚˜๋ฅผ ์ „๋‹ฌ ํ™•์ธ ์‹ ํ˜ธ๋กœ ์ธ์ •ํ•œ๋‹ค. ์‹ ํ˜ธ๊ฐ€ ์—†์œผ๋ฉด ์ข…์ „๋Œ€๋กœ rc 4 ๋กœ fail-closed ํ•˜๊ณ , ๊ทธ ๋ฐ–์˜ ์˜ค๋ฅ˜(`transport_error` ๋“ฑ)๋Š” accepted ๋กœ ์ทจ๊ธ‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋‹จ์ผ ์ฃผ์ž… ๊ณ„์•ฝ(์žฌ์ „์†ก ์—†์Œ)์€ ์œ ์ง€๋œ๋‹ค. 5. **๋ž˜ํผ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ack ์œ ์˜ˆ `MAM_INJECT_ACK_GRACE_S` (Issue #14, `multi-agent-mux-delegate-job`)**: `send_keys_safe` ๊ฐ€ ์ „๋‹ฌ์„ ํ™•์ธํ•˜์ง€ ๋ชปํ•ด๋„ ์žก์„ `agent_blocked` ๋กœ ๋‹ซ๊ธฐ ์ „์— `MAM_INJECT_ACK_GRACE_S`(๊ธฐ๋ณธ 10์ดˆ) ๋™์•ˆ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ๋ฅผ ๊ด€์ธกํ•˜๊ณ , `pending` ์ด ์•„๋‹Œ ์ƒํƒœ(์—์ด์ „ํŠธ์˜ `started` ๋“ฑ)๊ฐ€ ๋ณด์ด๋ฉด ์ „๋‹ฌ๋œ ๊ฒƒ์œผ๋กœ ๋ณด๊ณ  ์ง„ํ–‰ํ•œ๋‹ค(`_inject_acknowledged`). 6. **R1 ์œ ์˜ˆ์˜ pane busy ํ† ํฐ ์ธ์ • (Issue #14, `_agent_still_working`)**: ๊ตฌ๋…์ž idle ํƒ€์ž„์•„์›ƒ ํ›„ R1 ์œ ์˜ˆ๊ฐ€ herdr `working` ์™ธ์— pane ์ตœ๊ทผ 8์ค„์˜ `esc to interrupt` ํ† ํฐ๋„ ์ž‘์—… ์ค‘ ๊ทผ๊ฑฐ๋กœ ๋ฐ›์•„๋“ค์ธ๋‹ค. idle ํƒ€์ž„์•„์›ƒ๋ณด๋‹ค ๊ธด codex ๋ฆฌ๋ทฐ๊ฐ€ ํ„ด ๋„์ค‘ `deadline_timeout` ์œผ๋กœ ๋‹ซํžˆ๊ณ  ์ดํ›„ `completed` ๊ฐ€ ๊ฑฐ๋ถ€๋˜๋˜ ๋ฌธ์ œ๋ฅผ ๋ฐฉ์ง€. #### โœ… ํ’ˆ์งˆ ๊ฒ€์ฆ - **T1-7 ํŠธ๋ฆฌ๊ฑฐ ๋ณ€๊ฒฝ** (`tests/test_issue6_fail_closed.py`): ์ฃผ์ž… ์‹คํŒจ ์œ ๋ฐœ๊ฐ’์„ `prompt_wait_result=timeout` โ†’ `transport_error` ๋กœ ๋ณ€๊ฒฝ(Issue #14 ์ดํ›„ `timeout` ์€ accepted ์ œ์ถœ์ด๋ฏ€๋กœ ๋” ์ด์ƒ ํ™•์ • ์‹คํŒจ๊ฐ€ ์•„๋‹˜)ํ•˜๊ณ  `MAM_INJECT_ACK_GRACE_S=1` ๋กœ ๋Œ€๊ธฐ๋ฅผ ๋‹จ์ถ•. -- **์‹ ๊ทœ ํ…Œ์ŠคํŠธ 13๊ฐœ**: `tests/test_issue9_server_env.py` 5๊ฐœ(๋งˆ์ปค ์ œ๊ฑฐ, `MAM_NO_AUTOSTART` ๋ถ€ํŠธ์ŠคํŠธ๋žฉ ์ƒ๋žตยทํ˜ธ์ถœ ์ „๋‹ฌ, status ์กฐํšŒ ๋ฌด๊ธฐ๋™, ์ •์  opt-out, `ls` ๋ฒˆ์—ญ), `tests/test_issue14_delivery_ack.py` 8๊ฐœ(busy ํ† ํฐยทpane ๋ณ€ํ™” ํ™•์ธ, ๋ฌด์‹ ํ˜ธ fail-closed, ์„ ํ–‰ busy ํ† ํฐ ๋ฐฐ์ œ, `transport_error` ๋น„์ˆ˜์šฉ, mock herdr timeout ํ™•์ธ, ๋ž˜ํผ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ack, idle ํƒ€์ž„์•„์›ƒ pane busy ์œ ์˜ˆ). +- **์‹ ๊ทœ ํ…Œ์ŠคํŠธ 16๊ฐœ**: `tests/test_issue9_server_env.py` 8๊ฐœ(๋งˆ์ปค ์ œ๊ฑฐ, `MAM_NO_AUTOSTART` ๋ถ€ํŠธ์ŠคํŠธ๋žฉ ์ƒ๋žตยทํ˜ธ์ถœ ์ „๋‹ฌ, status ์กฐํšŒ ๋ฌด๊ธฐ๋™, ์ •์  opt-out, `ls` ๋ฒˆ์—ญ(์ตœ์†Œยทherdr 0.9 ์‹ค์ œ ์˜ค๋ฅ˜ ๋ด‰ํˆฌ), ์„ฑ๊ณต ๋ชฉ๋ก ์† `server_not_running` ๋ฌธ์ž์—ด ๋น„์˜คํŒ, ๊ทธ ๋ฐ–์˜ ์‹คํŒจ unknown ์œ ์ง€, reconcile drift A ๋น„๋ฐœ์ƒ), `tests/test_issue14_delivery_ack.py` 8๊ฐœ(busy ํ† ํฐยทpane ๋ณ€ํ™” ํ™•์ธ, ๋ฌด์‹ ํ˜ธ fail-closed, ์„ ํ–‰ busy ํ† ํฐ ๋ฐฐ์ œ, `transport_error` ๋น„์ˆ˜์šฉ, mock herdr timeout ํ™•์ธ, ๋ž˜ํผ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ack, idle ํƒ€์ž„์•„์›ƒ pane busy ์œ ์˜ˆ). - **ํ…Œ์ŠคํŠธ ๊ธฐ๋Œ€๊ฐ’ ์ •์ •**: `tests/test_a4_adapter_contract.py` ๊ฐ€ `4d444ce` ์˜ agy `exit_key=/exit` ๋ณ€๊ฒฝ์„ ๋ฐ˜์˜ํ•˜๋„๋ก ์ˆ˜์ •(๊ทธ๋™์•ˆ ์ „์ฒด ์Šค์œ„ํŠธ์—์„œ 1๊ฑด ์‹คํŒจํ•˜๋˜ ๋ฌธ์ œ ํ•ด์†Œ). - **์‹ค์ธก (์ปค๋ฐ‹ `497633f` ๊ธฐ๋ก)**: ์ƒˆ๋กœ ๋ถ€ํŠธ์ŠคํŠธ๋žฉํ•œ ์„œ๋ฒ„์— ๋งˆ์ปค๊ฐ€ ์—†๊ณ  claude pane ์˜ transcript ์ €์žฅ์ด ๋ณต๊ตฌ๋จ, codex ์žก `d04ebcb5` ์˜ `started`/`completed` ๊ฐ€ `agent_blocked` ์—†์ด ์ˆ˜๋ฝ๋จ. diff --git a/tests/test_issue14_delivery_ack.py b/tests/test_issue14_delivery_ack.py index 99ba462..48ff0d1 100644 --- a/tests/test_issue14_delivery_ack.py +++ b/tests/test_issue14_delivery_ack.py @@ -229,7 +229,7 @@ def test_wrapper_honours_registry_ack_over_unconfirmed_inject(tmp_path, mock_her assert ack.returncode == 0, ack.stderr stdout, stderr = proc.communicate(timeout=90) - assert "acknowledged it; continuing" in stderr, f"ack guard did not fire:\n{stderr}\n{stdout}" + assert "treating the prompt as delivered and continuing" in stderr, f"ack guard did not fire:\n{stderr}\n{stdout}" assert "ํ”„๋กฌํ”„ํŠธ ์ฃผ์ž… ์‹คํŒจ" not in stderr assert proc.returncode == 3, f"expected the broker_unavailable path (rc=3), got {proc.returncode}\n{stderr}" diff --git a/tests/test_issue9_server_env.py b/tests/test_issue9_server_env.py index 429d679..84be8d5 100644 --- a/tests/test_issue9_server_env.py +++ b/tests/test_issue9_server_env.py @@ -15,6 +15,8 @@ import subprocess import sys from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parent.parent RECONCILE_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" STATUS_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh" @@ -54,6 +56,24 @@ KEPT_CONFIG = { "MAM_CUSTOM_SETTING": "kept", } +# What herdr 0.9 writes to stderr (exit 1) for `agent list` against a stopped +# session server. +REAL_DOWN_ENVELOPE = { + "id": "cli:agent:list", + "error": { + "code": "server_not_running", + "message": "no herdr server is running at /tmp/sessions/down-srv/herdr.sock; " + "run `herdr session attach down-srv` to start or attach it", + }, +} + +# A successful list in which the down-server code appears only as data: in an +# agent name (the sanitizer allows underscores) and in unrelated metadata. +LISTING_WITH_SUBSTRING = {"result": {"agents": [ + {"name": "server_not_running-codex", "agent": "codex"}, + {"name": "creator-claude-01", "agent": "claude", "title": "server_not_running"}, +]}} + def _install_fake_herdr(sandbox: Path) -> dict: fakebin = sandbox / "fakebin" @@ -72,6 +92,12 @@ def _install_fake_herdr(sandbox: Path) -> dict: return env +def _fake_agent_list(sandbox: Path, body: str) -> None: + """Replace the fake's fallback answer (what `agent list` gets) with `body`.""" + fake = sandbox / "fakebin" / "herdr" + fake.write_text(FAKE_HERDR.replace('print(json.dumps({"result": {"agents": []}}))', body)) + + def _calls(env: dict) -> list: log = Path(env["FAKE_HERDR_LOG"]) if not log.exists(): @@ -90,6 +116,15 @@ def _run_through_shim(sandbox: Path, env: dict, session: str) -> subprocess.Comp ) +def _shim_ls(sandbox: Path, env: dict, server: str) -> subprocess.CompletedProcess: + """The exact `ls` query reconcile.sh sends for each registered server.""" + lib = sandbox / ".agents" / "skills" / "lib.sh" + return subprocess.run( + ["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr -L {server} ls -F "#{{session_name}}|#{{session_created}}"'], + capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=60, + ) + + def test_bootstrap_strips_agent_runtime_markers(mam_sandbox): env = _install_fake_herdr(mam_sandbox) env.update(RUNTIME_MARKERS) @@ -160,22 +195,85 @@ def test_reconcile_and_status_opt_out_of_autostart_statically(): f"{script.name} must opt out of the shim's server bootstrap" -def test_ls_reports_down_server_as_no_server_running(mam_sandbox): +@pytest.mark.parametrize("envelope", [ + {"error": {"code": "server_not_running"}}, + REAL_DOWN_ENVELOPE, +], ids=["minimal", "herdr-0.9"]) +def test_ls_reports_down_server_as_no_server_running(mam_sandbox, envelope): """reconcile.sh treats a nonzero `ls` as 'empty' only when stderr says 'no server running'; anything else flips herdr_confirmed=False for every server. The shim must therefore translate server_not_running explicitly.""" env = _install_fake_herdr(mam_sandbox) env["MAM_NO_AUTOSTART"] = "1" - fake = mam_sandbox / "fakebin" / "herdr" - fake.write_text(FAKE_HERDR.replace( - 'print(json.dumps({"result": {"agents": []}}))', - 'sys.stderr.write(json.dumps({"error": {"code": "server_not_running"}}) + "\\n"); sys.exit(1)', - )) - lib = mam_sandbox / ".agents" / "skills" / "lib.sh" - res = subprocess.run( - ["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr -L down-srv ls -F "#{{session_name}}|#{{session_created}}"'], - capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=60, - ) + _fake_agent_list(mam_sandbox, + f'sys.stderr.write({json.dumps(json.dumps(envelope))} + "\\n"); sys.exit(1)') + + res = _shim_ls(mam_sandbox, env, "down-srv") assert res.returncode != 0 assert "no server running" in res.stderr, res.stderr assert res.stdout.strip() == "" + + +def test_ls_lists_agents_whose_names_contain_server_not_running(mam_sandbox): + """A successful list is never a down server, whatever text it carries. + Matching the bare substring let one agent named server_not_running-codex + hide every agent its server returned.""" + env = _install_fake_herdr(mam_sandbox) + env["MAM_NO_AUTOSTART"] = "1" + _fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})") + + res = _shim_ls(mam_sandbox, env, "live-srv") + assert res.returncode == 0, res.stderr + assert "no server running" not in res.stderr, res.stderr + names = [line.split("|", 1)[0] for line in res.stdout.splitlines() if line.strip()] + assert names == ["server_not_running-codex", "creator-claude-01"], res.stdout + + +@pytest.mark.parametrize("stderr_text", [ + json.dumps({"error": {"code": "transport_error", + "message": "socket reset while probing server_not_running"}}), + "warning: server_not_running cache entry is stale", + "", +], ids=["other-code", "plain-text", "empty"]) +def test_ls_leaves_other_failures_unknown(mam_sandbox, stderr_text): + """Only the structured code means "down". Any other failure keeps the + unknown path (nonzero exit without 'no server running'), so reconcile + drops herdr_confirmed instead of reading a confirmed-empty server.""" + env = _install_fake_herdr(mam_sandbox) + env["MAM_NO_AUTOSTART"] = "1" + _fake_agent_list(mam_sandbox, + f'sys.stderr.write({json.dumps(stderr_text)} + "\\n"); sys.exit(1)') + + res = _shim_ls(mam_sandbox, env, "flaky-srv") + assert res.returncode != 0 + assert "no server running" not in res.stderr, res.stderr + assert res.stdout.strip() == "" + + +def test_reconcile_keeps_live_rows_when_a_name_contains_server_not_running(mam_sandbox): + """End to end through reconcile.sh: the server answers a successful list in + which the down-server code appears as data. Both registered rows must stay + alive with herdr_confirmed intact, so drift A cannot mark them terminated.""" + env = _install_fake_herdr(mam_sandbox) + _fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})") + rows = "".join( + f" - name: {name}\n" + " status: running\n" + " herdr_session: live-srv\n" + " pane:\n" + " cwd: /tmp/nonexistent-workspace\n" + for name in ("server_not_running-codex", "creator-claude-01") + ) + (mam_sandbox / ".mam" / "agent-sessions.yaml").write_text("herdr_sessions:\n" + rows) + reconcile = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + + res = subprocess.run(["bash", str(reconcile), "--once", "--emit-diff", "--dry-run"], + capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=120) + assert res.returncode == 0, res.stderr + + report = json.loads(res.stdout) + assert report["herdr_confirmed"] is True, report + alive = set(report["herdr_sessions_alive"]) + assert {"server_not_running-codex|live-srv", "creator-claude-01|live-srv"} <= alive, alive + drift_a = [d for d in report["drifts"] if d.get("class") == "A"] + assert not drift_a, drift_a