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:
1 parent
4d444ce6cd
commit
497633f64a
9 files changed
+551
-18
No files matched your search
+86
-14
@@ -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 <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 '
|
||||
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 <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]
|
||||
# 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
|
||||
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
1. **Via Environment Variable**:
|
||||
```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
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <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).
|
||||
- **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]
|
||||
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"
|
||||
|
||||
Reference in new issue
Block a user