Implement full E2E, integration, component, and unit tests, and resolve all leftover tmux-to-herdr issues in UI and core scripts
This commit is contained in:
+121
-21
@@ -28,7 +28,7 @@ AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.
|
||||
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
|
||||
for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do
|
||||
if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then
|
||||
export PATH="$dir:$PATH"
|
||||
export PATH="$PATH:$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -58,7 +58,16 @@ _resolve_real_herdr_path() {
|
||||
_init_herdr_isolation() {
|
||||
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
|
||||
mkdir -p "$wrapper_dir"
|
||||
cat <<'EOF' > "$wrapper_dir/herdr"
|
||||
if [ -x "$wrapper_dir/herdr" ]; then
|
||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||
export PATH="$wrapper_dir:$PATH"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tmp_file
|
||||
tmp_file=$(mktemp "$wrapper_dir/herdr.XXXXXX")
|
||||
cat <<'EOF' > "$tmp_file"
|
||||
#!/usr/bin/env bash
|
||||
# Herdr-to-Herdr translation shim wrapper
|
||||
set -euo pipefail
|
||||
@@ -81,6 +90,16 @@ _resolve_real_herdr() {
|
||||
}
|
||||
REAL_HERDR=$(_resolve_real_herdr)
|
||||
|
||||
# Support parsing -L <server> before the subcommand
|
||||
while [ "${1:-}" = "-L" ]; do
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "herdr shim: -L requires an argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
export HERDR_SERVER_NAME="$2"
|
||||
shift 2
|
||||
done
|
||||
|
||||
wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
cmd="${1:-}"
|
||||
if [ -z "$cmd" ]; then
|
||||
@@ -89,12 +108,20 @@ if [ -z "$cmd" ]; then
|
||||
fi
|
||||
shift
|
||||
|
||||
|
||||
case "$cmd" in
|
||||
has-session)
|
||||
sess=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-t) sess="$2"; shift 2 ;;
|
||||
-t)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -t requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
sess="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
@@ -104,12 +131,29 @@ case "$cmd" in
|
||||
name="" ws="" run_cmd=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-s) name="$2"; shift 2 ;;
|
||||
-c) ws="$2"; shift 2 ;;
|
||||
-s)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -s requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
name="$2"
|
||||
shift 2
|
||||
;;
|
||||
-c)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -c requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
ws="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|-x|-y) shift ;;
|
||||
*) run_cmd="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$run_cmd" ]; then
|
||||
run_cmd="${SHELL:-/bin/bash}"
|
||||
fi
|
||||
"$REAL_HERDR" workspace create --label "${HERDR_SERVER_NAME:-default}" --cwd "${ws:-.}" >/dev/null 2>&1 || true
|
||||
|
||||
parsed=$(python3 -c "
|
||||
@@ -136,13 +180,14 @@ print('\t'.join(env_flags) + '\n' + ' '.join(binary_tokens))
|
||||
ws_label="${HERDR_SERVER_NAME:-default}"
|
||||
ws_id="w1"
|
||||
if [ "$ws_label" != "default" ]; then
|
||||
ws_id=$("$REAL_HERDR" workspace list 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
ws_id=$("$REAL_HERDR" workspace list 2>/dev/null | WS_LABEL="$ws_label" python3 -c "
|
||||
import sys, os, json
|
||||
try:
|
||||
ws_label = os.environ.get('WS_LABEL', '')
|
||||
data = json.loads(sys.stdin.read())
|
||||
res = data.get('result', data)
|
||||
for ws in res.get('workspaces', []):
|
||||
if ws.get('label') == '$ws_label' or ws.get('workspace_id') == '$ws_label':
|
||||
if ws.get('label') == ws_label or ws.get('workspace_id') == ws_label:
|
||||
print(ws.get('workspace_id'))
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
@@ -157,7 +202,14 @@ except Exception:
|
||||
sess=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-t) sess="$2"; shift 2 ;;
|
||||
-t)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -t requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
sess="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
@@ -168,8 +220,22 @@ except Exception:
|
||||
sess="" format=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-t) sess="$2"; shift 2 ;;
|
||||
-F) format="$2"; shift 2 ;;
|
||||
-t)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -t requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
sess="$2"
|
||||
shift 2
|
||||
;;
|
||||
-F)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -F requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
format="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
@@ -198,7 +264,14 @@ except Exception:
|
||||
sess=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-t) sess="$2"; shift 2 ;;
|
||||
-t)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -t requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
sess="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
@@ -208,7 +281,14 @@ except Exception:
|
||||
sess="" key=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-t) sess="$2"; shift 2 ;;
|
||||
-t)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -t requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
sess="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) key="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
@@ -221,7 +301,14 @@ except Exception:
|
||||
text=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-b) buf="$2"; shift 2 ;;
|
||||
-b)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -b requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
buf="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) text="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
@@ -231,7 +318,14 @@ except Exception:
|
||||
sess=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-t) sess="$2"; shift 2 ;;
|
||||
-t)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Error: -t requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
sess="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
@@ -249,7 +343,11 @@ try:
|
||||
data = json.load(sys.stdin)
|
||||
res = data.get('result', data)
|
||||
for a in res.get('agents', []):
|
||||
print(f\"{a['name']}|0\")
|
||||
try:
|
||||
name = a.get('name') or a.get('agent') or 'unknown'
|
||||
print(f\"{name}|0\")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
" || true
|
||||
@@ -259,7 +357,8 @@ except Exception:
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$wrapper_dir/herdr"
|
||||
chmod +x "$tmp_file"
|
||||
mv -f "$tmp_file" "$wrapper_dir/herdr"
|
||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||
export PATH="$wrapper_dir:$PATH"
|
||||
fi
|
||||
@@ -334,7 +433,7 @@ name = os.environ['SESSION_NAME']
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == name:
|
||||
print(s.get('herdr_workspace', 'default'))
|
||||
print(s.get('herdr_workspace') or s.get('herdr_server') or 'default')
|
||||
sys.exit(0)
|
||||
print(os.environ.get('HERDR_SERVER_NAME', 'default'))
|
||||
")
|
||||
@@ -342,13 +441,14 @@ print(os.environ.get('HERDR_SERVER_NAME', 'default'))
|
||||
echo "w1"
|
||||
else
|
||||
local ws_id
|
||||
ws_id=$(herdr workspace list 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
ws_id=$(herdr workspace list 2>/dev/null | RAW_LABEL="$raw_label" python3 -c "
|
||||
import sys, os, json
|
||||
try:
|
||||
raw_label = os.environ.get('RAW_LABEL', '')
|
||||
data = json.loads(sys.stdin.read())
|
||||
res = data.get('result', data)
|
||||
for ws in res.get('workspaces', []):
|
||||
if ws.get('label') == '$raw_label' or ws.get('workspace_id') == '$raw_label':
|
||||
if ws.get('label') == raw_label or ws.get('workspace_id') == raw_label:
|
||||
print(ws.get('workspace_id'))
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
#
|
||||
# This is a reference wrapper: it shells out to the python scripts that live
|
||||
# next to it. Copy it into your project and customise as needed. It never hard
|
||||
# fails if `claude`/`codex`/`tmux` are missing — it prints what it would run.
|
||||
# fails if `claude`/`codex`/`herdr` are missing — it prints what it would run.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -28,6 +28,8 @@ pick_python() {
|
||||
local py_bin
|
||||
if [[ -n "${DELEGATE_JOB_PYTHON:-}" ]]; then
|
||||
py_bin="$DELEGATE_JOB_PYTHON"
|
||||
elif [[ -n "${AGENT_PYTHON_BIN:-}" ]] && [[ -x "$AGENT_PYTHON_BIN" ]]; then
|
||||
py_bin="$AGENT_PYTHON_BIN"
|
||||
elif [[ -x "${WORKDIR:-.}/.venv/bin/python" ]]; then
|
||||
py_bin="${WORKDIR}/.venv/bin/python"
|
||||
elif [[ -x ".venv/bin/python" ]]; then
|
||||
@@ -56,7 +58,7 @@ multi-agent-mux-delegate-job <command> [options]
|
||||
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
|
||||
[--reviewer-session <reviewer_session>] [--max-iterations <count>]
|
||||
[--counterpart-role <role_name>] [--strict-role-check]
|
||||
# The skill is tmux-interactive only; --mode print was removed.
|
||||
# The skill is herdr-interactive only; --mode print was removed.
|
||||
status --job <id> [--registry-dir <dir>]
|
||||
list [--registry-dir <dir>]
|
||||
verify --job <id> --validate <script> [--registry-dir <dir>]
|
||||
@@ -66,10 +68,10 @@ EOF
|
||||
}
|
||||
|
||||
# ---- arg parsing helpers --------------------------------------------------
|
||||
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="tmux:claude"
|
||||
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="herdr:claude"
|
||||
TIMEOUT=3600; IDLE_TIMEOUT=120; VALIDATE=""; DRY_RUN=0
|
||||
JOB_ID=""; REGISTRY_DIR="$REGISTRY_DIR_DEFAULT"; DELEGATE_ROLE="Worker"
|
||||
TYPE="direct"; REVIEWER="hermes"; REVIEWER_SESSION="tmux:hermes"; MAX_ITERATIONS=5
|
||||
TYPE="direct"; REVIEWER="hermes"; REVIEWER_SESSION="herdr:hermes"; MAX_ITERATIONS=5
|
||||
DEFAULT_COUNTERPART_ROLE="Reviewer"
|
||||
COUNTERPART_ROLE="$DEFAULT_COUNTERPART_ROLE"
|
||||
STRICT_ROLE_CHECK=0
|
||||
@@ -153,8 +155,15 @@ EOF
|
||||
break
|
||||
fi
|
||||
else
|
||||
echo "ERROR: subscriber died early (pid=$sub_pid, check $logf)" >&2
|
||||
exit 1
|
||||
wait "$sub_pid" 2>/dev/null
|
||||
local sub_exit=$?
|
||||
if [ $sub_exit -eq 0 ]; then
|
||||
sub_ready=1
|
||||
break
|
||||
else
|
||||
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
@@ -224,7 +233,7 @@ EOF
|
||||
|
||||
# 1-1) Provision job directory and write iteration brief.md (MAM Job Restructuring)
|
||||
local job_dir="$REGISTRY_DIR/$JOB_ID"
|
||||
local clean_session="${current_session#tmux:}"
|
||||
local clean_session="${current_session#herdr:}"
|
||||
if [[ "$DRY_RUN" != "1" ]]; then
|
||||
mkdir -p "$job_dir"
|
||||
cat <<EOF > "$job_dir/brief.md"
|
||||
@@ -267,8 +276,15 @@ EOF
|
||||
break
|
||||
fi
|
||||
else
|
||||
echo "ERROR: subscriber died early (pid=$sub_pid, check $logf)" >&2
|
||||
exit 1
|
||||
wait "$sub_pid" 2>/dev/null
|
||||
local sub_exit=$?
|
||||
if [ $sub_exit -eq 0 ]; then
|
||||
sub_ready=1
|
||||
break
|
||||
else
|
||||
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
@@ -387,38 +403,38 @@ run_agent() {
|
||||
# The skill is INTERACTIVE-ONLY. We never invoke `claude -p` or any other
|
||||
# one-shot print mode, because:
|
||||
# - claude -p exits the moment stdin is drained, so there's nothing to
|
||||
# `tmux attach` to afterwards.
|
||||
# `herdr session attach` to afterwards.
|
||||
# - fire-and-forget via wrapper defeats the whole point of the audit log
|
||||
# (you can't tell what happened if the agent crashes mid-turn).
|
||||
# - the job registry already gives us an authoritative completion signal,
|
||||
# so we don't need a wrapper-side exit code to know "done".
|
||||
# The user attaches with `tmux attach -t <session>` and types follow-up
|
||||
# The user attaches with `herdr session attach <session>` and types follow-up
|
||||
# prompts themselves. We pre-load the first prompt via stdin and `read`
|
||||
# keeps the pane open after the agent exits so the user can review.
|
||||
if [ "$AGENT" = "human" ]; then
|
||||
echo "[human agent] complete the task, then run publish_event.py --event completed"
|
||||
return
|
||||
fi
|
||||
local sess="${target_session#tmux:}"
|
||||
local sess="${target_session#herdr:}"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
echo "[dry-run] would delegate task to running agent '$AGENT' in tmux session '$sess' with instructions:"
|
||||
echo "[dry-run] would delegate task to running agent '$AGENT' in herdr session '$sess' with instructions:"
|
||||
echo "----"; echo "$instructions"; echo "----"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "ERROR: this skill requires tmux (interactive agent sessions)." >&2
|
||||
echo " Install with: brew install tmux (or your package manager)" >&2
|
||||
if ! command -v herdr >/dev/null 2>&1; then
|
||||
echo "ERROR: this skill requires herdr (interactive agent sessions)." >&2
|
||||
echo " Ensure herdr is installed and executable." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local _tmux="tmux"
|
||||
if [ -n "${TMUX_SERVER_NAME:-}" ]; then
|
||||
_tmux="tmux -L $TMUX_SERVER_NAME"
|
||||
local _herdr="herdr"
|
||||
if [ -n "${HERDR_SERVER_NAME:-}" ]; then
|
||||
_herdr="herdr -L $HERDR_SERVER_NAME"
|
||||
fi
|
||||
|
||||
if ! $_tmux has-session -t "$sess" 2>/dev/null; then
|
||||
if ! $_herdr has-session -t "$sess" 2>/dev/null; then
|
||||
echo "ERROR: 에이전트 세션 '$sess'이 존재하지 않습니다. 작업을 위임하기 전에 먼저 에이전트 세션을 기동해 주세요." >&2
|
||||
echo " 팁: 'multi-agent-mux-resume' 또는 'multi-agent-mux-create'를 통해 에이전트를 먼저 생성할 수 있습니다." >&2
|
||||
return 1
|
||||
@@ -431,7 +447,7 @@ run_agent() {
|
||||
import os, json
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
name = os.environ.get('SESS_NAME')
|
||||
for s in d.get('tmux_sessions', []):
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == name:
|
||||
print(s.get('role', ''))
|
||||
break
|
||||
@@ -483,7 +499,7 @@ else:
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $_tmux attach -t $sess)"
|
||||
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $_herdr session attach $sess)"
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,11 @@ def main(argv=None) -> int:
|
||||
|
||||
expected_ids: Set[str] = {j["job_id"] for j in jobs}
|
||||
tokens = {j["job_id"]: j.get("auth_token") for j in jobs}
|
||||
seqs = {j["job_id"]: int(j.get("last_seq", 0)) for j in jobs}
|
||||
seqs = {}
|
||||
for j in jobs:
|
||||
jid = j["job_id"]
|
||||
last_seq = int(j.get("last_seq", 0))
|
||||
seqs[jid] = max(0, last_seq - 1)
|
||||
watcher = _Watcher(expected_ids, tokens, seqs)
|
||||
|
||||
# Resolve timeouts from CLI, falling back to the (first) job's settings.
|
||||
|
||||
@@ -159,7 +159,7 @@ import os, json
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
target_agent = os.environ.get('TARGET_AGENT')
|
||||
reviewers = [s.get('name') for s in d.get('herdr_sessions', [])
|
||||
if 'reviewer' in s.get('role', '') and s.get('name') != target_agent]
|
||||
if 'reviewer' in s.get('role', '').lower() and s.get('name') != target_agent]
|
||||
print(','.join(reviewers))
|
||||
"
|
||||
}
|
||||
@@ -199,7 +199,7 @@ import os, json
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
planner = ''
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if 'planner' in s.get('role', ''):
|
||||
if 'planner' in s.get('role', '').lower():
|
||||
planner = s.get('name')
|
||||
break
|
||||
print(planner)
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
# Exit codes: 0 = ok | 1 = YAML not found | 2 = error
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
SKILLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
LIB_SH="$SKILLS_DIR/lib.sh"
|
||||
source "$LIB_SH"
|
||||
export WORKSPACE_ROOT
|
||||
|
||||
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/multi-agent-mux-monitor}"
|
||||
@@ -119,7 +121,7 @@ _changed = False
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('delegate_job_id') == _jid and s.get('status') == 'running':
|
||||
_name = s.get('name')
|
||||
_srv = s.get('herdr_server') or 'default'
|
||||
_srv = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
|
||||
if _event == 'completed':
|
||||
s['delegate_job_id'] = None
|
||||
print('MQTT Monitor: job completed on ' + str(_name) + ' — session kept alive', flush=True)
|
||||
@@ -316,10 +318,13 @@ except NameError:
|
||||
import subprocess
|
||||
d = {}
|
||||
try:
|
||||
ws_root = os.environ.get('WORKSPACE_ROOT')
|
||||
if not ws_root:
|
||||
ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||
script = f"source '{ws_root}/.agents/skills/lib.sh' && load_state_json"
|
||||
lib_sh = os.environ.get('LIB_SH')
|
||||
if not lib_sh:
|
||||
ws_root = os.environ.get('WORKSPACE_ROOT')
|
||||
if not ws_root:
|
||||
ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||
lib_sh = os.path.join(ws_root, '.agents/skills/lib.sh')
|
||||
script = f"source '{lib_sh}' && load_state_json"
|
||||
out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
|
||||
d = json.loads(out.decode('utf-8'))
|
||||
except Exception:
|
||||
@@ -337,7 +342,7 @@ unique_servers = {'default'}
|
||||
if 'HERDR_SERVER_NAME' in os.environ:
|
||||
unique_servers.add(os.environ['HERDR_SERVER_NAME'])
|
||||
for s in d.get('herdr_sessions', []):
|
||||
srv = s.get('herdr_server') or 'default'
|
||||
srv = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
|
||||
unique_servers.add(srv)
|
||||
|
||||
try:
|
||||
@@ -391,7 +396,7 @@ if herdr_confirmed:
|
||||
# (없으면 herdr-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
|
||||
if s.get('status') in ('terminated', 'archived', 'stopped'):
|
||||
continue
|
||||
srv = s.get('herdr_server') or 'default'
|
||||
srv = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
|
||||
if (name, srv) not in alive_set:
|
||||
s['status'] = 'terminated'
|
||||
s['terminated_at'] = now_iso
|
||||
@@ -643,7 +648,7 @@ if not actions:
|
||||
PYEOF
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
printf '%s' "$RECON_SRC" | env_python "$AGENT_SESSIONS_YAML"
|
||||
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" env_python "$AGENT_SESSIONS_YAML"
|
||||
else
|
||||
printf '%s' "$RECON_SRC" | atomic_dump_yaml "$AGENT_SESSIONS_YAML"
|
||||
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" atomic_dump_yaml "$AGENT_SESSIONS_YAML"
|
||||
fi
|
||||
|
||||
@@ -37,7 +37,8 @@ if [ -z "$UUID" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||
export HERDR_SERVER_NAME
|
||||
|
||||
# 2. If herdr is alive, print warning or attach.
|
||||
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
|
||||
@@ -33,7 +33,8 @@ done
|
||||
[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; }
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
||||
|
||||
export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||
export HERDR_SERVER_NAME
|
||||
|
||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
|
||||
if [ -z "$AGENT" ]; then
|
||||
|
||||
@@ -93,7 +93,7 @@ def get_job_status(s):
|
||||
sessions_detail = []
|
||||
for s in d.get('herdr_sessions', []):
|
||||
name = s.get('name', '?')
|
||||
server = s.get('herdr_server') or 'default'
|
||||
server = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
|
||||
jid, jstatus = get_job_status(s)
|
||||
pane = s.get('pane') or {}
|
||||
sessions_detail.append({
|
||||
@@ -197,7 +197,7 @@ if not sessions:
|
||||
print("(no sessions registered)")
|
||||
for s in sessions:
|
||||
name = s.get('name', '?')
|
||||
server = s.get('herdr_server') or 'default'
|
||||
server = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
|
||||
status = s.get('status', '?')
|
||||
herdr = 'alive' if f"{name}|{server}" in alive else 'dead'
|
||||
cmd = (s.get('pane') or {}).get('cmd', '?')
|
||||
|
||||
@@ -168,7 +168,7 @@ class _Header extends StatelessWidget {
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_Pill(text: s.status.toUpperCase()),
|
||||
_Pill(text: s.tmuxAlive ? 'TMUX ALIVE' : 'TMUX DEAD'),
|
||||
_Pill(text: s.herdrAlive ? 'HERDR ALIVE' : 'HERDR DEAD'),
|
||||
if (s.role != null) _Pill(text: s.role!.toUpperCase()),
|
||||
_Pill(text: 'SERVER: ${s.server}'),
|
||||
],
|
||||
|
||||
+4
-4
@@ -47,7 +47,7 @@ class SessionTable extends StatelessWidget {
|
||||
DataColumn2(label: Text('NAME'), size: ColumnSize.L),
|
||||
DataColumn2(label: Text('SERVER'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('YAML'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('TMUX'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('HERDR'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('CMD'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('RESUME'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('JOB_ID'), size: ColumnSize.S),
|
||||
@@ -79,7 +79,7 @@ class SessionTable extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
DataCell(_StatusChip(status: s.status)),
|
||||
DataCell(_TmuxChip(alive: s.tmuxAlive)),
|
||||
DataCell(_HerdrChip(alive: s.herdrAlive)),
|
||||
DataCell(
|
||||
Text(
|
||||
s.pane.cmd ?? '?',
|
||||
@@ -150,9 +150,9 @@ class _StatusChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TmuxChip extends StatelessWidget {
|
||||
class _HerdrChip extends StatelessWidget {
|
||||
final bool alive;
|
||||
const _TmuxChip({required this.alive});
|
||||
const _HerdrChip({required this.alive});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
+2
-2
@@ -40,8 +40,8 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
Future<void> _startPty() async {
|
||||
try {
|
||||
final pty = await PtySession.start(
|
||||
'tmux',
|
||||
['-L', widget.serverName, 'attach', '-t', widget.sessionName],
|
||||
'herdr',
|
||||
['-L', widget.serverName, 'session', 'attach', widget.sessionName],
|
||||
);
|
||||
if (!mounted) {
|
||||
pty.close();
|
||||
|
||||
@@ -15,7 +15,7 @@ void main() {
|
||||
name: 'demo-session',
|
||||
server: 'default',
|
||||
status: 'running',
|
||||
tmuxAlive: true,
|
||||
herdrAlive: true,
|
||||
pane: const Pane(
|
||||
pid: 123,
|
||||
cwd: '/x',
|
||||
@@ -26,7 +26,7 @@ void main() {
|
||||
resumeState: 'yes',
|
||||
jobId: '-',
|
||||
jobStatus: '-',
|
||||
attachCommand: 'tmux attach -t demo-session',
|
||||
attachCommand: 'herdr session attach demo-session',
|
||||
driftClasses: const ['B'],
|
||||
),
|
||||
];
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// The tmux pane backing a session row, as reported by `sessions_detail`.
|
||||
/// The herdr pane backing a session row, as reported by `sessions_detail`.
|
||||
@immutable
|
||||
class Pane {
|
||||
final int? pid;
|
||||
|
||||
@@ -13,7 +13,7 @@ class SessionRow {
|
||||
final String name;
|
||||
final String server;
|
||||
final String status;
|
||||
final bool tmuxAlive;
|
||||
final bool herdrAlive;
|
||||
final Pane pane;
|
||||
final String? role;
|
||||
final String resumeState;
|
||||
@@ -28,7 +28,7 @@ class SessionRow {
|
||||
required this.name,
|
||||
required this.server,
|
||||
required this.status,
|
||||
required this.tmuxAlive,
|
||||
required this.herdrAlive,
|
||||
required this.pane,
|
||||
this.role,
|
||||
required this.resumeState,
|
||||
@@ -49,7 +49,7 @@ class SessionRow {
|
||||
name: json['name'] as String? ?? '?',
|
||||
server: json['server'] as String? ?? 'default',
|
||||
status: json['status'] as String? ?? '?',
|
||||
tmuxAlive: json['tmux_alive'] as bool? ?? false,
|
||||
herdrAlive: json['herdr_alive'] as bool? ?? false,
|
||||
pane: Pane.fromSessionJson(json),
|
||||
role: json['role'] as String?,
|
||||
resumeState: json['resume_state'] as String? ?? '?',
|
||||
@@ -64,3 +64,4 @@ class SessionRow {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-8
@@ -4,14 +4,14 @@ import 'drift_entry.dart';
|
||||
import 'session_row.dart';
|
||||
|
||||
/// The full parsed result of one `status.sh --json` call: the untouched
|
||||
/// pre-D8 keys (timestamp/yaml_path/tmux_sessions_alive/tmux_confirmed/
|
||||
/// pre-D8 keys (timestamp/yaml_path/herdr_sessions_alive/herdr_confirmed/
|
||||
/// drifts/actions) plus the additive `sessions_detail` -> [SessionRow].
|
||||
@immutable
|
||||
class SessionsSnapshot {
|
||||
final DateTime timestamp;
|
||||
final String yamlPath;
|
||||
final List<String> tmuxSessionsAlive;
|
||||
final bool tmuxConfirmed;
|
||||
final List<String> herdrSessionsAlive;
|
||||
final bool herdrConfirmed;
|
||||
final List<DriftEntry> drifts;
|
||||
final List<String> actions;
|
||||
final List<SessionRow> sessions;
|
||||
@@ -19,8 +19,8 @@ class SessionsSnapshot {
|
||||
const SessionsSnapshot({
|
||||
required this.timestamp,
|
||||
required this.yamlPath,
|
||||
required this.tmuxSessionsAlive,
|
||||
required this.tmuxConfirmed,
|
||||
required this.herdrSessionsAlive,
|
||||
required this.herdrConfirmed,
|
||||
required this.drifts,
|
||||
required this.actions,
|
||||
required this.sessions,
|
||||
@@ -32,11 +32,11 @@ class SessionsSnapshot {
|
||||
DateTime.tryParse(json['timestamp'] as String? ?? '')?.toUtc() ??
|
||||
DateTime.now().toUtc(),
|
||||
yamlPath: json['yaml_path'] as String? ?? '',
|
||||
tmuxSessionsAlive:
|
||||
(json['tmux_sessions_alive'] as List<dynamic>? ?? const [])
|
||||
herdrSessionsAlive:
|
||||
(json['herdr_sessions_alive'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList(growable: false),
|
||||
tmuxConfirmed: json['tmux_confirmed'] as bool? ?? false,
|
||||
herdrConfirmed: json['herdr_confirmed'] as bool? ?? false,
|
||||
drifts: (json['drifts'] as List<dynamic>? ?? const [])
|
||||
.map((e) => DriftEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(growable: false),
|
||||
@@ -49,3 +49,4 @@ class SessionsSnapshot {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -37,9 +37,9 @@ class SessionService {
|
||||
throw StatusFetchException('preflight failed: bash is missing or not executable');
|
||||
}
|
||||
|
||||
final tmuxResult = await runCommand(['tmux', '-V'], timeout: timeout);
|
||||
if (tmuxResult.rc != 0) {
|
||||
throw StatusFetchException('preflight failed: tmux is missing or not executable');
|
||||
final herdrResult = await runCommand(['herdr', '-V'], timeout: timeout);
|
||||
if (herdrResult.rc != 0) {
|
||||
throw StatusFetchException('preflight failed: herdr is missing or not executable');
|
||||
}
|
||||
|
||||
_preflightChecked = true;
|
||||
|
||||
+11
-10
@@ -24,18 +24,18 @@ void main() {
|
||||
{
|
||||
"timestamp": "2026-07-16T11:56:54Z",
|
||||
"yaml_path": "/x/.mam/agent-sessions.yaml",
|
||||
"tmux_sessions_alive": ["a|default"],
|
||||
"tmux_confirmed": true,
|
||||
"herdr_sessions_alive": ["a|default"],
|
||||
"herdr_confirmed": true,
|
||||
"drifts": [{"class": "B", "name": "a", "msg": "registered: a"}],
|
||||
"actions": ["registered: a"],
|
||||
"sessions_detail": [
|
||||
{
|
||||
"name": "a", "server": "default", "status": "running",
|
||||
"tmux_alive": true, "cmd": "claude", "role": "creator",
|
||||
"herdr_alive": true, "cmd": "claude", "role": "creator",
|
||||
"resume_state": "yes", "job_id": "-", "job_status": "-",
|
||||
"pane_cwd": "/x", "attach_command": "tmux attach -t a",
|
||||
"pane_cwd": "/x", "attach_command": "herdr session attach a",
|
||||
"drift_classes": ["B"], "pane_pid": 123, "cmd_full": "claude --foo",
|
||||
"start_command": "tmux new-session ...", "last_visible_status": "running"
|
||||
"start_command": "herdr agent start ...", "last_visible_status": "running"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -43,7 +43,7 @@ void main() {
|
||||
|
||||
final snapshot = SessionsSnapshot.fromJson(json);
|
||||
|
||||
expect(snapshot.tmuxConfirmed, isTrue);
|
||||
expect(snapshot.herdrConfirmed, isTrue);
|
||||
expect(snapshot.drifts, hasLength(1));
|
||||
expect(snapshot.drifts.single.driftClass, 'B');
|
||||
expect(snapshot.sessions, hasLength(1));
|
||||
@@ -56,7 +56,7 @@ void main() {
|
||||
expect(row.isTerminal, isFalse);
|
||||
expect(row.pane.pid, 123);
|
||||
expect(row.pane.cmdFull, 'claude --foo');
|
||||
expect(row.attachCommand, 'tmux attach -t a');
|
||||
expect(row.attachCommand, 'herdr session attach a');
|
||||
});
|
||||
|
||||
test('SessionsSnapshot.fromJson tolerates missing optional fields', () {
|
||||
@@ -64,7 +64,7 @@ void main() {
|
||||
as Map<String, dynamic>;
|
||||
final snapshot = SessionsSnapshot.fromJson(json);
|
||||
|
||||
expect(snapshot.tmuxConfirmed, isFalse);
|
||||
expect(snapshot.herdrConfirmed, isFalse);
|
||||
expect(snapshot.sessions.single.name, 'bare');
|
||||
expect(snapshot.sessions.single.resumeState, '?');
|
||||
expect(snapshot.sessions.single.pane.pid, isNull);
|
||||
@@ -85,8 +85,8 @@ void main() {
|
||||
final snapshot = await service.fetchSnapshot();
|
||||
|
||||
expect(snapshot.yamlPath, isNotEmpty);
|
||||
// sessions_detail row count must match tmux_sessions_alive's distinct names.
|
||||
final aliveNames = snapshot.tmuxSessionsAlive
|
||||
// sessions_detail row count must match herdr_sessions_alive's distinct names.
|
||||
final aliveNames = snapshot.herdrSessionsAlive
|
||||
.map((entry) => entry.split('|').first)
|
||||
.toSet();
|
||||
final detailNames = snapshot.sessions.map((s) => s.name).toSet();
|
||||
@@ -95,3 +95,4 @@ void main() {
|
||||
timeout: const Timeout(Duration(seconds: 15)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
BIN
Binary file not shown.
@@ -182,7 +182,9 @@ class PtySession {
|
||||
// A. Disassociate controlling terminal
|
||||
setsid();
|
||||
|
||||
// B. Isolate from nested TMUX environments (§6.7)
|
||||
// B. Isolate from nested TMUX/Herdr environments (§6.7)
|
||||
// We must unset TMUX and TMUX_PANE to ensure the child shell is properly isolated
|
||||
// from any parent terminal multiplexer session, preventing unexpected nested behavior.
|
||||
unsetenv(tmuxNamePtr.cast<ffi.Char>());
|
||||
unsetenv(tmuxPaneNamePtr.cast<ffi.Char>());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user