diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index d094bfd..b15529d 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -194,6 +194,12 @@ done _MAM_SESSION="${HERDR_SESSION_NAME:-default}" if [ "$_MAM_SESSION" = "default" ]; then _MAM_SESSION="" +elif [ "${MAM_NO_AUTOSTART:-0}" = "1" ]; then + # ISSUE-9: query-only callers (status.sh, reconcile.sh) must never start a + # daemon as a side effect of looking. A session whose server is down just + # answers server_not_running, which every caller already reads as "no live + # agents" - the same answer a freshly bootstrapped, empty server would give. + : else _mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c " import sys, json, os @@ -216,9 +222,25 @@ except Exception: # installed by default on macOS, so spawn via Python's # start_new_session=True (os.setsid in the child) instead - portable # across macOS and Linux, and lib_py already depends on Python. + # + # ISSUE-9: the caller is usually an agent CLI itself (a Claude Code + # orchestrator pane), and herdr hands the daemon's environment to every + # pane it later spawns. An inherited CLAUDE_CODE_CHILD_SESSION makes each + # sub-agent claude disable transcript saving (no .jsonl, so resume + # is impossible), and the other markers advertise the orchestrator's own + # identity to its sub-agents. Drop only per-session runtime markers - user + # configuration such as CLAUDE_PROJECT_DIR or CLAUDE_CONFIG_DIR still + # inherits. The family ids mirror what orc_onboard.sh auto-detects. _mam_server_pid=$(python3 -c ' -import subprocess, sys -p = subprocess.Popen(sys.argv[1:], start_new_session=True, +import os, subprocess, sys +deny = ("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") +env = {k: v for k, v in os.environ.items() if k not in deny} +p = subprocess.Popen(sys.argv[1:], start_new_session=True, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL) print(p.pid) @@ -1218,7 +1240,21 @@ except Exception: if [ "$_sess" = "default" ]; then _sess="" fi - _real_herdr agent list 2>/dev/null | \ + # ISSUE-9: under MAM_NO_AUTOSTART the session's server may simply be down. + # Say so in the wording reconcile.sh already treats as "empty" ("no server + # 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. + _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 + 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 ' import json, os, subprocess, sys, time @@ -2183,6 +2219,21 @@ except Exception: # blank rows; window on non-blank lines or top-anchored dialogs are invisible. _pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; } +# _sks_agent_status [target] — herdr agent_status, empty when unavailable. +_sks_agent_status() { + local raw + raw="$(_sks_herdr agent get "${2:-$1}" 2>/dev/null || _sks_herdr agent get "$1" 2>/dev/null || true)" + [ -n "$raw" ] || { echo ""; return 0; } + "$(_delegate_py_bin)" -c ' +import sys, json +try: + d = json.loads(sys.stdin.read()) + print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "") +except Exception: + print("") +' <<< "$raw" 2>/dev/null || true +} + # _wait_session_gone [max_sec=5] # Reactive loop to wait until a herdr session goes away (happy path returns early). _wait_session_gone() { @@ -2324,6 +2375,8 @@ except Exception: # SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single # Escape per poll and re-check. NEVER a blind Enter. # 3. Native herdr 0.8+ RPC fast path: agent prompt handles atomic text + enter submission. +# An accepted submission whose status watch timed out is confirmed by polling +# for SKS_DELIVERY_GRACE_S (default 15 s) - see ISSUE-14 below. # 4. Paste via unique buffer; verify the text landed (marker visible). # 5. Submit C-m; verify submission (marker left the input area AND the pane # changed); retry up to 3 times. @@ -2395,6 +2448,9 @@ send_keys_safe() { # Native herdr 0.8+ fast path: agent prompt handles atomic submission if [ "${SKS_PROMPT_WAIT:-1}" = "1" ]; then local p_out="" p_rc=0 + # ISSUE-14: pre-submit snapshot for the delivery-confirmation poll below. + local rpc_pre_pane="" + [ "$_q_rc" != "2" ] && rpc_pre_pane=$(_pane_capture "$sess") local _p_tmp _p_tmp=$(mktemp 2>/dev/null || echo "/tmp/sks_p_${sess}_$$") if _sks_herdr agent prompt "$agent_target" "$text" --wait --until working --timeout 5000 >"$_p_tmp" 2>&1; then @@ -2429,19 +2485,35 @@ send_keys_safe() { # Prompt was dispatched via RPC; on any error (empty output, timeout, stalled, transport error, etc.), # do NOT resubmit via paste buffer (single injection contract). Check agent get after 2s. sleep 2 - local check_raw check_st - check_raw="$(_sks_herdr agent get "$agent_target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)" - check_st="$("$(_delegate_py_bin)" -c ' -import sys, json -try: - d = json.loads(sys.stdin.read()) - print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "") -except Exception: - print("") -' <<< "$check_raw" 2>/dev/null || true)" - if [ "$check_st" = "working" ]; then + if [ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ]; then return 0 fi + # ISSUE-14: `timeout` / `agent_prompt_stalled` mean herdr ACCEPTED the + # submission and sent the input - only its post-submit status watch came + # up empty. An agent whose agent_status never leaves `idle` mid-turn + # (codex under herdr 0.9.0) therefore failed every delivered prompt here: + # the job was closed as agent_blocked and the agent's own `started` / + # `completed`, published seconds later, were refused as late. For an + # accepted submission keep observing for SKS_DELIVERY_GRACE_S (default + # 15) and take any positive signal: agent_status working, a busy token + # the pane did not show before the prompt, or the pane moving on from + # its pre-submit snapshot. No signal at all still fails closed (rc 4). + if printf '%s\n' "$p_out" | grep -Eq '"code": *"(timeout|agent_prompt_stalled)"'; then + local g_deadline=$(( $(date +%s) + ${SKS_DELIVERY_GRACE_S:-15} )) + local pre_busy_rpc=0 cur_pane + printf '%s\n' "$rpc_pre_pane" | grep -Fq "esc to interrupt" && pre_busy_rpc=1 + while [ "$(date +%s)" -lt "$g_deadline" ]; do + [ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ] && return 0 + if [ "$_q_rc" != "2" ]; then + cur_pane=$(_pane_capture "$sess") + if [ "$pre_busy_rpc" -eq 0 ] && printf '%s\n' "$cur_pane" | grep -Fq "esc to interrupt"; then + return 0 + fi + [ -n "$cur_pane" ] && [ "$cur_pane" != "$rpc_pre_pane" ] && return 0 + fi + sleep 1 + done + fi echo "send_keys_safe: delivery unknown/failed ($sess)" >&2 return 4 fi diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index dd016e3..68c0112 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -72,6 +72,8 @@ Additionally, you can specify `--herdr-workspace ` (default: workspace slu Under the hood this maps to a real, separate herdr **session** (`herdr --session ` — its own socket, its own `agent list`/`workspace list`, completely invisible to the default session and vice versa), not just a workspace label inside the same server. `lib.sh`'s shim bootstraps the named session's server headlessly (`herdr --session server`, backgrounded) the first time it's needed, and scopes every subsequent herdr call to it automatically — this headless bootstrap is what lets it work even when the skill itself is running from inside another herdr-managed pane (a plain interactive `herdr --session ` launch is blocked there by herdr's "nested herdr is disabled" guard; headless `server` mode isn't). +The bootstrapped server receives a **sanitized copy of the caller's environment** (Issue #9): per-session agent runtime markers — `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`, the Claude Code messaging/bridge/pid markers, and the `ANTIGRAVITY_CONVERSATION_ID` / `HERMES_SESSION_ID` / `GROK_SESSION_ID` / `OPENCODE_SESSION_ID` equivalents — are dropped, because herdr hands its environment to every pane it spawns and an inherited `CLAUDE_CODE_CHILD_SESSION` silently turns transcript saving off in every sub-agent claude (no `.jsonl`, so `multi-agent-mux-resume` can never find it). User configuration such as `CLAUDE_PROJECT_DIR` or `CLAUDE_CONFIG_DIR` is inherited unchanged. Query-only callers set `MAM_NO_AUTOSTART=1` to skip the bootstrap entirely (`status.sh` and `reconcile.sh` do). A server bootstrapped before this fix keeps its polluted environment until it is restarted (`herdr session stop `, then create/resume again). + ### How to use 1. **Via Environment Variable**: ```bash 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 eaa5c4f..fe26298 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 @@ -564,6 +564,27 @@ _disk_status() { "$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$jid.json'); print(json.load(open(p)).get('status','')) if os.path.exists(p) else print('')" 2>/dev/null || true } +# ISSUE-14: send_keys_safe can only report what herdr lets it observe. When it +# gives up, the prompt may still have landed - codex under herdr 0.9.0 never +# 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. +_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 + return 0 + fi + [ "$(date +%s)" -ge "$deadline" ] && return 1 + sleep 1 + done +} + _map_sub_rc() { local sub_rc="$1" job_id="$2" local job_status="running" @@ -829,7 +850,7 @@ inject_agent() { fi echo "살아있는 에이전트 세션 '$sess'에 작업을 위임합니다..." - if ! send_keys_safe "$sess" "$instructions" "$job_id"; then + if ! send_keys_safe "$sess" "$instructions" "$job_id" && ! _inject_acknowledged "$job_id"; then trap - EXIT echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess' (프롬프트 잠금 의심)" >&2 return 1 diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 32f6bf7..0e7b55d 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -20,6 +20,12 @@ SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" LIB_SH="$SKILLS_DIR/lib.sh" [ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh" source "$LIB_SH" + +# ISSUE-9: the reconciler only observes and marks - it never creates a pane - +# so no herdr call it makes (the per-server `ls` sweep on every heartbeat, in +# --dry-run and in --subscribe alike) may bootstrap a server as a side effect. +# A server that is down simply has no live sessions. +export MAM_NO_AUTOSTART=1 if [ -n "${AGENT_SESSIONS_STATE_DIR:-}" ]; then echo "Notice: AGENT_SESSIONS_STATE_DIR is set but has no effect; the monitor keeps no state directory (C-2)." >&2 fi diff --git a/.agents/skills/multi-agent-mux-status/SKILL.md b/.agents/skills/multi-agent-mux-status/SKILL.md index 1b06cca..4c87560 100644 --- a/.agents/skills/multi-agent-mux-status/SKILL.md +++ b/.agents/skills/multi-agent-mux-status/SKILL.md @@ -110,6 +110,7 @@ lab-paper-pdf2md-creator-claude default running alive clau - **Do NOT use this skill to drive mutations** — the output is a snapshot, not a call to action. If you need to fix drifts, run `multi-agent-mux-monitor` or `multi-agent-mux-resume` / `multi-agent-mux-stop` manually. - **Read-only is enforced by script** — `status.sh` opens the YAML with `open(path)` (no `'w'`), never calls `herdr kill-session`, never writes anywhere. The `reconcile.sh --dry-run` mode is the same path. +- **It never starts a herdr server either** — `status.sh` exports `MAM_NO_AUTOSTART=1`, so the `lib.sh` shim skips its headless server bootstrap and a session whose isolated server is down is simply reported `dead`. Before Issue #9 a bare status query could leave a fresh `herdr --session server` daemon (and an empty pane) behind, inheriting the caller's environment. - **If `agent-sessions.yaml` is malformed** — print the YAML error verbatim and exit 1. Do NOT attempt recovery (that's `multi-agent-mux-stop --purge-conversation` or manual edit's job). - **Sessions outside the `-creator-*` naming convention** are still shown but tagged `ad-hoc` — they didn't go through `multi-agent-mux-create` and aren't tracked in YAML. diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index d668bbd..90ba9e6 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -7,6 +7,11 @@ # Usage: bash status.sh [--json] set -euo pipefail +# ISSUE-9: a status query must not bootstrap a herdr server as a side effect. +# With this set, the lib.sh shim reports a session whose isolated server is +# down as dead instead of starting a headless daemon just to answer. +export MAM_NO_AUTOSTART=1 + source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" RECONCILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/multi-agent-mux-monitor/scripts/reconcile.sh" diff --git a/tests/test_issue14_delivery_ack.py b/tests/test_issue14_delivery_ack.py new file mode 100644 index 0000000..219740a --- /dev/null +++ b/tests/test_issue14_delivery_ack.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Issue #14 — a prompt that herdr accepted must not be reported as undelivered. + +`herdr agent prompt --wait --until working --timeout 5000` answers `timeout` (or +`agent_prompt_stalled`) when the input was sent but no `working` state was +observed afterwards. Agents whose agent_status never leaves `idle` mid-turn +(codex under herdr 0.9.0) therefore had every delivered prompt closed as +`agent_blocked`, and their own `started` / `completed` events, published seconds +later, were refused as late. send_keys_safe now confirms an accepted submission +through the pane, and the delegate wrapper additionally honours the registry's +own acknowledgement before finalising a job as an inject failure. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SKILLS = REPO_ROOT / ".agents" / "skills" +LIB_SH = SKILLS / "lib.sh" +WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" +REGISTRY_PY = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "registry.py" + +TIMEOUT_JSON = '{"error":{"code":"timeout","message":"timed out waiting for agent status"},"id":"cli:agent:prompt"}' +IDLE_JSON = '{"result":{"agent":{"agent_status":"idle"}}}' + + +def _base_env(cwd: Path, extra=None) -> dict: + env = dict(os.environ) + env["DELEGATE_JOB_PYTHON"] = sys.executable + env["AGENT_PYTHON_BIN"] = sys.executable + env["WORKSPACE_ROOT"] = str(cwd) + env["HOME"] = str(cwd) + if extra: + env.update({k: str(v) for k, v in extra.items()}) + return env + + +def _run_lib_sh(snippet: str, cwd: Path, env=None, timeout=60) -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", "-c", f'source "{LIB_SH}" >/dev/null 2>&1; {snippet}'], + capture_output=True, text=True, cwd=str(cwd), env=_base_env(cwd, env), timeout=timeout, + ) + + +def _codex_like_mock(pre_pane: str, post_pane: str, prompt_code: str = "timeout") -> str: + """A bash `_sks_herdr` stand-in replaying what herdr 0.9.0 shows for codex: + the RPC returns `timeout`, agent_status stays `idle`, and only the pane + reveals what happened. Captures switch from PRE to POST once the prompt + has been submitted. Any paste-buffer use is a contract violation.""" + return f''' +_pane_quiescent() {{ return 0; }} +_pane_dialog_open() {{ return 1; }} +FLAG="$WORKSPACE_ROOT/.prompt-submitted" +rm -f "$FLAG" +_sks_herdr() {{ + case "${{1:-}}" in + agent) + case "${{2:-}}" in + prompt) touch "$FLAG"; echo '{{"error":{{"code":"{prompt_code}","message":"x"}}}}' >&2; return 1 ;; + get) echo '{IDLE_JSON}'; return 0 ;; + esac ;; + capture-pane) + if [ -e "$FLAG" ]; then printf '%s\\n' "{post_pane}"; else printf '%s\\n' "{pre_pane}"; fi + return 0 ;; + paste-buffer|set-buffer|delete-buffer|send-keys) + echo "PASTE_PATH_USED: $*" >&2; return 0 ;; + esac + return 0 +}} +''' + + +CODEX_IDLE = "identified.\\n› Ask Codex to do anything" +CODEX_WORKING = "Working (0s • esc to interrupt)\\n› Ask Codex to do anything" +CODEX_ANSWERED = "• OK\\n› Ask Codex to do anything" + + +def _assert_single_injection(res: subprocess.CompletedProcess): + assert "PASTE_PATH_USED" not in res.stderr, f"must not fall back to paste-buffer: {res.stderr}" + + +# --------------------------------------------------------------------------- +# lib.sh: accepted submission confirmed through the pane +# --------------------------------------------------------------------------- +def test_accepted_timeout_confirmed_by_busy_token(tmp_path): + script = _codex_like_mock(CODEX_IDLE, CODEX_WORKING) + \ + 'send_keys_safe "reviewer-codex-01" "review the triage report" "job_14a"' + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "5"}) + assert res.returncode == 0, f"rc={res.returncode}\n{res.stderr}" + _assert_single_injection(res) + assert "delivery unknown" not in res.stderr + + +def test_accepted_timeout_confirmed_by_pane_change_after_fast_turn(tmp_path): + """A turn that already finished before the first poll shows no busy token, + only the answer; the pane moving on from its pre-submit snapshot is enough.""" + script = _codex_like_mock(CODEX_IDLE, CODEX_ANSWERED) + \ + 'send_keys_safe "reviewer-codex-01" "Reply with OK" "job_14b"' + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "5"}) + assert res.returncode == 0, f"rc={res.returncode}\n{res.stderr}" + _assert_single_injection(res) + + +def test_accepted_timeout_with_no_signal_still_fails_closed(tmp_path): + """Nothing moves, nothing works: the grace window expires into rc=4 exactly + as before, without ever re-submitting.""" + script = _codex_like_mock(CODEX_IDLE, CODEX_IDLE) + \ + 'send_keys_safe "reviewer-codex-01" "silent prompt" "job_14c"' + started = time.time() + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "2"}) + elapsed = time.time() - started + assert res.returncode == 4, f"rc={res.returncode}\n{res.stderr}" + assert "delivery unknown/failed" in res.stderr + _assert_single_injection(res) + assert 2 <= elapsed < 20, f"grace window not honoured: {elapsed:.1f}s" + + +def test_pre_existing_busy_token_is_not_taken_as_delivery(tmp_path): + """A busy token that was already on screen before the prompt is a leftover + of the previous turn, not evidence for this one.""" + script = _codex_like_mock(CODEX_WORKING, CODEX_WORKING) + \ + 'send_keys_safe "reviewer-codex-01" "prompt into a busy pane" "job_14d"' + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "2"}) + assert res.returncode == 4, f"rc={res.returncode}\n{res.stderr}" + _assert_single_injection(res) + + +def test_transport_error_is_not_treated_as_accepted(tmp_path): + """Only `timeout` / `agent_prompt_stalled` mean the input was sent. A + transport-level failure keeps the single 2 s probe and rc=4, even if the + pane happens to change meanwhile (T2-13 contract, pinned with pane noise).""" + script = _codex_like_mock(CODEX_IDLE, CODEX_WORKING, prompt_code="transport_error") + \ + 'send_keys_safe "reviewer-codex-01" "prompt" "job_14e"' + started = time.time() + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "30"}) + elapsed = time.time() - started + assert res.returncode == 4, f"rc={res.returncode}\n{res.stderr}" + _assert_single_injection(res) + assert elapsed < 15, f"no grace poll may run for a non-accepted code: {elapsed:.1f}s" + + +def test_mock_herdr_timeout_with_idle_status_is_confirmed(tmp_path, mock_herdr): + """Same scenario through the shared mock herdr: `timeout` from agent prompt, + agent_status pinned to idle, the pane gaining the busy token after + submission. Exactly one prompt call, no paste-buffer fallback.""" + state_file = Path(mock_herdr) + state = json.loads(state_file.read_text()) + state["agents"]["agt_codex"] = { + "agent": "codex", + "agent_status": "idle", + "prompt_wait_result": "timeout", + "buffer": "Ready", + } + state_file.write_text(json.dumps(state, indent=2)) + + res = _run_lib_sh( + 'send_keys_safe "agt_codex" "review please" "job_14f"', + tmp_path, env={"SKS_IDLE_HANDSHAKE": "0", "SKS_DELIVERY_GRACE_S": "5"}, + ) + assert res.returncode == 0, f"rc={res.returncode}\n{res.stderr}" + + final_state = json.loads(state_file.read_text()) + calls = final_state.get("calls", []) + prompt_calls = [c for c in calls if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"] + paste_calls = [c for c in calls if len(c) >= 2 and (c[0] in ("paste-buffer", "set-buffer") or c[1] in ("paste-buffer", "set-buffer"))] + assert len(prompt_calls) == 1, f"prompt must be submitted exactly once, got {len(prompt_calls)}" + assert not paste_calls, f"no paste-buffer fallback allowed: {paste_calls}" + + +# --------------------------------------------------------------------------- +# wrapper: the registry's own acknowledgement beats an unconfirmed inject +# --------------------------------------------------------------------------- +def test_wrapper_honours_registry_ack_over_unconfirmed_inject(tmp_path, mock_herdr): + """The RPC fails outright, but the agent has already published `started` + (status running) by the time the wrapper decides: the job must not be + closed as agent_blocked. With the broker unreachable the wrapper then + follows its normal broker_unavailable path (rc=3), never the inject path.""" + reg_dir = tmp_path / "jobs" + reg_dir.mkdir(parents=True, exist_ok=True) + + state_file = Path(mock_herdr) + state = json.loads(state_file.read_text()) + state["agents"]["ack-agent"] = { + "agent": "codex", + "agent_status": "idle", + "prompt_wait_result": "transport_error", + } + state["prompt_wait_result"] = "transport_error" + state_file.write_text(json.dumps(state, indent=2)) + + env = _base_env(tmp_path, { + "MQTT_HOST": "127.0.0.1", "MQTT_PORT": "65534", + "SKS_IDLE_TIMEOUT_MS": "500", "MAM_INJECT_ACK_GRACE_S": "8", + }) + proc = subprocess.Popen( + [ + "bash", str(WRAPPER), "submit", + "--agent", "codex", + "--agent-session", "herdr:ack-agent", + "--prompt", "issue 14 ack", + "--registry-dir", str(reg_dir), + "--workdir", str(tmp_path), + "--timeout", "6", + "--idle-timeout", "2", + ], + cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + + jid = "" + for _ in range(100): + job_files = list(reg_dir.glob("*.json")) + if job_files: + jid = job_files[0].stem + break + time.sleep(0.1) + assert jid, "job was not registered" + + # The agent acknowledges the job out of band, as codex does via publish_event. + ack = subprocess.run( + [sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir), + "update", "--job", jid, "--status", "running"], + capture_output=True, text=True, cwd=str(tmp_path), env=env, + ) + 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 "프롬프트 주입 실패" not in stderr + assert proc.returncode == 3, f"expected the broker_unavailable path (rc=3), got {proc.returncode}\n{stderr}" + + events_log = reg_dir / f"{jid}.events.log" + if not events_log.exists(): + events_log = reg_dir / jid / "events.log" + if events_log.exists(): + assert "agent_blocked" not in events_log.read_text(), events_log.read_text() + job_data = json.loads((reg_dir / f"{jid}.json").read_text()) + assert job_data.get("status") != "pending" diff --git a/tests/test_issue6_fail_closed.py b/tests/test_issue6_fail_closed.py index a3f6dce..4c43361 100644 --- a/tests/test_issue6_fail_closed.py +++ b/tests/test_issue6_fail_closed.py @@ -259,12 +259,15 @@ def test_t1_7_custom_registry_dir_inject_failure(tmp_path, mock_herdr): # Set up mock herdr with an agent, but prompt will fail state_file = Path(mock_herdr) state = json.loads(state_file.read_text()) + # A transport-level RPC failure is a definite inject failure. (`timeout` is + # not: since Issue #14 it means herdr accepted the submission and only its + # status watch timed out, which send_keys_safe then confirms via the pane.) state["agents"]["fail-agent"] = { "agent": "claude", "agent_status": "idle", - "prompt_wait_result": "timeout", # will fail prompt + "prompt_wait_result": "transport_error", # will fail prompt } - state["prompt_wait_result"] = "timeout" + state["prompt_wait_result"] = "transport_error" state_file.write_text(json.dumps(state, indent=2)) res = _run_wrapper( @@ -278,7 +281,7 @@ def test_t1_7_custom_registry_dir_inject_failure(tmp_path, mock_herdr): "--timeout", "15", "--idle-timeout", "10", ], - env={"SKS_BUSY_TIMEOUT": "1", "SKS_IDLE_TIMEOUT_MS": "500"}, + env={"SKS_BUSY_TIMEOUT": "1", "SKS_IDLE_TIMEOUT_MS": "500", "MAM_INJECT_ACK_GRACE_S": "1"}, cwd=tmp_path, timeout=60, ) diff --git a/tests/test_issue9_server_env.py b/tests/test_issue9_server_env.py new file mode 100644 index 0000000..429d679 --- /dev/null +++ b/tests/test_issue9_server_env.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Issue #9 — herdr server bootstrap must not inherit the caller's agent-runtime +markers, and query-only skills must never bootstrap a server at all. + +The bootstrap lives in the `.mam/shim/herdr` wrapper that lib.sh writes, so every +test drives that shim against a fake `herdr` binary placed first on PATH: it logs +each call, answers `session list --json` with "not running", and dumps its own +environment when asked to run as the `server`. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +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" + +FAKE_HERDR = r'''#!/usr/bin/env python3 +import json, os, sys +args = sys.argv[1:] +with open(os.environ["FAKE_HERDR_LOG"], "a") as f: + f.write(json.dumps(args) + "\n") +if args[-1:] == ["server"]: + with open(os.environ["FAKE_HERDR_ENV_DUMP"], "w") as f: + json.dump(dict(os.environ), f) + sys.exit(0) +if args[-3:] == ["session", "list", "--json"]: + print(json.dumps({"sessions": []})) + sys.exit(0) +print(json.dumps({"result": {"agents": []}})) +''' + +# Per-session runtime markers of the calling agent CLI: none of these may reach +# the daemon (and through it, every pane it later spawns). +RUNTIME_MARKERS = { + "CLAUDECODE": "1", + "CLAUDE_CODE_CHILD_SESSION": "1", + "CLAUDE_CODE_SESSION_ID": "orchestrator-uuid", + "CLAUDE_CODE_MESSAGING_SOCKET": "/tmp/cc-socks/1.sock", + "CLAUDE_PID": "12345", + "ANTIGRAVITY_CONVERSATION_ID": "agy-uuid", + "HERMES_SESSION_ID": "hermes-uuid", + "GROK_SESSION_ID": "grok-uuid", + "OPENCODE_SESSION_ID": "opencode-uuid", +} + +# Ordinary configuration must keep flowing through unchanged. +KEPT_CONFIG = { + "CLAUDE_CONFIG_DIR": "/keep/claude-config", + "MAM_CUSTOM_SETTING": "kept", +} + + +def _install_fake_herdr(sandbox: Path) -> dict: + fakebin = sandbox / "fakebin" + fakebin.mkdir(exist_ok=True) + binary = fakebin / "herdr" + binary.write_text(FAKE_HERDR) + binary.chmod(0o755) + env = dict(os.environ) + env.update({ + "PATH": f"{fakebin}:{env['PATH']}", + "FAKE_HERDR_LOG": str(sandbox / "fake_herdr_calls.log"), + "FAKE_HERDR_ENV_DUMP": str(sandbox / "server_env.json"), + "AGENT_PYTHON_BIN": sys.executable, + "DELEGATE_JOB_PYTHON": sys.executable, + }) + return env + + +def _calls(env: dict) -> list: + log = Path(env["FAKE_HERDR_LOG"]) + if not log.exists(): + return [] + return [json.loads(line) for line in log.read_text().splitlines() if line.strip()] + + +def _run_through_shim(sandbox: Path, env: dict, session: str) -> subprocess.CompletedProcess: + """`herdr agent list` through lib.sh's shim, scoped to an isolated session + whose server the fake reports as not running.""" + lib = sandbox / ".agents" / "skills" / "lib.sh" + env = dict(env, HERDR_SESSION_NAME=session) + return subprocess.run( + ["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr agent list'], + 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) + env.update(KEPT_CONFIG) + + res = _run_through_shim(mam_sandbox, env, "issue9-probe") + assert res.returncode == 0, res.stderr + + server_calls = [c for c in _calls(env) if c[-1:] == ["server"]] + assert server_calls == [["--session", "issue9-probe", "server"]], _calls(env) + + dump = Path(env["FAKE_HERDR_ENV_DUMP"]) + assert dump.exists(), "server was bootstrapped but never received an environment" + server_env = json.loads(dump.read_text()) + + leaked = sorted(k for k in RUNTIME_MARKERS if k in server_env) + assert not leaked, f"runtime markers leaked into the herdr server: {leaked}" + for key, value in KEPT_CONFIG.items(): + assert server_env.get(key) == value, f"{key} must be inherited unchanged" + # MAM's own configuration is configuration, not a marker. + assert server_env.get("CLAUDE_PROJECT_DIR") == os.environ["CLAUDE_PROJECT_DIR"] + for key in ("HOME", "PATH", "WORKSPACE_ROOT"): + assert key in server_env + + +def test_no_autostart_skips_bootstrap_but_still_routes_the_call(mam_sandbox): + env = _install_fake_herdr(mam_sandbox) + env["MAM_NO_AUTOSTART"] = "1" + + res = _run_through_shim(mam_sandbox, env, "issue9-quiet") + assert res.returncode == 0, res.stderr + + calls = _calls(env) + assert not any(c[-1:] == ["server"] for c in calls), f"server must not be started: {calls}" + assert not Path(env["FAKE_HERDR_ENV_DUMP"]).exists() + # The command itself still goes to the named session, whose server answers. + assert ["--session", "issue9-quiet", "agent", "list"] in calls, calls + + +def test_status_query_never_starts_a_server(mam_sandbox): + """status.sh sweeps every herdr_session named in the YAML; a stopped + isolated server must be reported dead, not bootstrapped to answer.""" + env = _install_fake_herdr(mam_sandbox) + (mam_sandbox / ".mam" / "agent-sessions.yaml").write_text( + "herdr_sessions:\n" + " - name: ghost-creator-claude\n" + " status: stopped\n" + " herdr_session: ghost-srv\n" + " pane:\n" + " cwd: /tmp/nonexistent-workspace\n" + ) + status_sh = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh" + + res = subprocess.run(["bash", str(status_sh), "--json"], capture_output=True, text=True, + cwd=str(mam_sandbox), env=env, timeout=120) + assert res.returncode == 0, res.stderr + + calls = _calls(env) + assert any("--session" in c and "ghost-srv" in c for c in calls), \ + f"the ghost-srv session was never queried: {calls}" + assert not any(c[-1:] == ["server"] for c in calls), f"status.sh started a server: {calls}" + assert not Path(env["FAKE_HERDR_ENV_DUMP"]).exists() + + +def test_reconcile_and_status_opt_out_of_autostart_statically(): + for script in (RECONCILE_SH, STATUS_SH): + assert "export MAM_NO_AUTOSTART=1" in script.read_text(), \ + f"{script.name} must opt out of the shim's server bootstrap" + + +def test_ls_reports_down_server_as_no_server_running(mam_sandbox): + """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, + ) + assert res.returncode != 0 + assert "no server running" in res.stderr, res.stderr + assert res.stdout.strip() == ""