fix(issue-9,14): sanitize herdr server env and confirm accepted prompt delivery

- Drop per-session agent runtime markers (CLAUDECODE, CLAUDE_CODE_CHILD_SESSION,
  CLAUDE_CODE_SESSION_ID, Claude Code messaging/bridge/pid markers, and the
  agy/hermes/grok/opencode session ids) from the environment lib.sh's shim hands
  to a bootstrapped `herdr --session <name> server`; user configuration such as
  CLAUDE_PROJECT_DIR still inherits
- Add MAM_NO_AUTOSTART=1 so query-only callers never bootstrap a server as a side
  effect; status.sh and reconcile.sh export it, and the shim's `ls` reports a down
  server as "no server running" so reconcile keeps herdr_confirmed instead of
  freezing drift detection for every server
- send_keys_safe: treat `timeout` / `agent_prompt_stalled` from `agent prompt
  --wait` as an accepted submission and confirm delivery for SKS_DELIVERY_GRACE_S
  (default 15 s) via agent_status working, a busy token the pane did not show
  before, or the pane moving on from its pre-submit snapshot; no signal at all
  still fails closed (rc 4). Codex under herdr 0.9.0 never reports `working`, so
  every delivered prompt was closed as agent_blocked and its later
  started/completed events refused
- delegate wrapper: before closing an unconfirmed inject as agent_blocked, honour
  the registry's own acknowledgement for MAM_INJECT_ACK_GRACE_S (default 10 s)
- Switch T1-7's failure trigger to transport_error (timeout now means accepted);
  add test_issue9_server_env.py (5 cases) and test_issue14_delivery_ack.py (7)
- Verified live: a freshly bootstrapped multi-agent-mux server carries no markers
  and the claude panes save transcripts again; codex job d04ebcb5 had its
  started/completed accepted with no agent_blocked

Closes #9
Closes #14

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-12 23:11:55 +09:00
1 parent 4d444ce6cd
commit 497633f64a
9 files changed
+551 -18

No files matched your search

+86 -14
View File
@@ -194,6 +194,12 @@ done
_MAM_SESSION="${HERDR_SESSION_NAME:-default}" _MAM_SESSION="${HERDR_SESSION_NAME:-default}"
if [ "$_MAM_SESSION" = "default" ]; then if [ "$_MAM_SESSION" = "default" ]; then
_MAM_SESSION="" _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 else
_mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c " _mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c "
import sys, json, os import sys, json, os
@@ -216,9 +222,25 @@ except Exception:
# installed by default on macOS, so spawn via Python's # installed by default on macOS, so spawn via Python's
# start_new_session=True (os.setsid in the child) instead - portable # start_new_session=True (os.setsid in the child) instead - portable
# across macOS and Linux, and lib_py already depends on Python. # 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 <uuid>.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 ' _mam_server_pid=$(python3 -c '
import subprocess, sys import os, subprocess, sys
p = subprocess.Popen(sys.argv[1:], start_new_session=True, 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, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL) stdin=subprocess.DEVNULL)
print(p.pid) print(p.pid)
@@ -1218,7 +1240,21 @@ except Exception:
if [ "$_sess" = "default" ]; then if [ "$_sess" = "default" ]; then
_sess="" _sess=""
fi 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" \ MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \
TZ=UTC LC_ALL=C python3 -c ' TZ=UTC LC_ALL=C python3 -c '
import json, os, subprocess, sys, time 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. # 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}"; } _pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; }
# _sks_agent_status <sess> [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 <sess> [max_sec=5] # _wait_session_gone <sess> [max_sec=5]
# Reactive loop to wait until a herdr session goes away (happy path returns early). # Reactive loop to wait until a herdr session goes away (happy path returns early).
_wait_session_gone() { _wait_session_gone() {
@@ -2324,6 +2375,8 @@ except Exception:
# SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single # SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single
# Escape per poll and re-check. NEVER a blind Enter. # 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. # 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). # 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 # 5. Submit C-m; verify submission (marker left the input area AND the pane
# changed); retry up to 3 times. # changed); retry up to 3 times.
@@ -2395,6 +2448,9 @@ send_keys_safe() {
# Native herdr 0.8+ fast path: agent prompt handles atomic submission # Native herdr 0.8+ fast path: agent prompt handles atomic submission
if [ "${SKS_PROMPT_WAIT:-1}" = "1" ]; then if [ "${SKS_PROMPT_WAIT:-1}" = "1" ]; then
local p_out="" p_rc=0 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 local _p_tmp
_p_tmp=$(mktemp 2>/dev/null || echo "/tmp/sks_p_${sess}_$$") _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 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.), # 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. # do NOT resubmit via paste buffer (single injection contract). Check agent get after 2s.
sleep 2 sleep 2
local check_raw check_st if [ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ]; then
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
return 0 return 0
fi 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 echo "send_keys_safe: delivery unknown/failed ($sess)" >&2
return 4 return 4
fi fi
@@ -72,6 +72,8 @@ Additionally, you can specify `--herdr-workspace <name>` (default: workspace slu
Under the hood this maps to a real, separate herdr **session** (`herdr --session <name>` — 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 <name> 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 <name>` launch is blocked there by herdr's "nested herdr is disabled" guard; headless `server` mode isn't). Under the hood this maps to a real, separate herdr **session** (`herdr --session <name>` — 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 <name> 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 <name>` 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 `<uuid>.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 <name>`, then create/resume again).
### How to use ### How to use
1. **Via Environment Variable**: 1. **Via Environment Variable**:
```bash ```bash
@@ -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 "$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() { _map_sub_rc() {
local sub_rc="$1" job_id="$2" local sub_rc="$1" job_id="$2"
local job_status="running" local job_status="running"
@@ -829,7 +850,7 @@ inject_agent() {
fi fi
echo "살아있는 에이전트 세션 '$sess'에 작업을 위임합니다..." 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 trap - EXIT
echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess' (프롬프트 잠금 의심)" >&2 echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess' (프롬프트 잠금 의심)" >&2
return 1 return 1
@@ -20,6 +20,12 @@ SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
LIB_SH="$SKILLS_DIR/lib.sh" LIB_SH="$SKILLS_DIR/lib.sh"
[ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh" [ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
source "$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 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 echo "Notice: AGENT_SESSIONS_STATE_DIR is set but has no effect; the monitor keeps no state directory (C-2)." >&2
fi fi
@@ -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. - **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. - **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 <name> 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). - **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 `<workspace>-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. - **Sessions outside the `<workspace>-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.
@@ -7,6 +7,11 @@
# Usage: bash status.sh [--json] # Usage: bash status.sh [--json]
set -euo pipefail 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" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
RECONCILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/multi-agent-mux-monitor/scripts/reconcile.sh" RECONCILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/multi-agent-mux-monitor/scripts/reconcile.sh"
+242
View File
@@ -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"
+6 -3
View File
@@ -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 # Set up mock herdr with an agent, but prompt will fail
state_file = Path(mock_herdr) state_file = Path(mock_herdr)
state = json.loads(state_file.read_text()) 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"] = { state["agents"]["fail-agent"] = {
"agent": "claude", "agent": "claude",
"agent_status": "idle", "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)) state_file.write_text(json.dumps(state, indent=2))
res = _run_wrapper( res = _run_wrapper(
@@ -278,7 +281,7 @@ def test_t1_7_custom_registry_dir_inject_failure(tmp_path, mock_herdr):
"--timeout", "15", "--timeout", "15",
"--idle-timeout", "10", "--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, cwd=tmp_path,
timeout=60, timeout=60,
) )
+181
View File
@@ -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() == ""