fix(refactor): address reviewer findings R-1..R-8, achieve 31/31 test pass
This commit is contained in:
+45
-28
@@ -127,7 +127,7 @@ except Exception:
|
||||
fi
|
||||
|
||||
_real_herdr() {
|
||||
local session="${HERDR_SERVER_NAME:-}"
|
||||
local session="${HERDR_SESSION_NAME:-}"
|
||||
if [ "$session" = "default" ]; then
|
||||
session=""
|
||||
fi
|
||||
@@ -547,25 +547,44 @@ PYEOF
|
||||
}
|
||||
|
||||
# Despite the name (kept for caller compatibility — resume/stop/update_yaml_resumed
|
||||
# all do `HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"`), this
|
||||
# all do `HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"`), this
|
||||
# returns the isolated herdr *session* name to use for this MAM session row, not
|
||||
# a workspace id. Real isolation is `--session <name>` (see `_MAM_SESSION` in the
|
||||
# generated wrapper) — a workspace label match provides no actual isolation
|
||||
# since agent/pane commands are server-global regardless of workspace.
|
||||
|
||||
resolve_herdr_session() {
|
||||
local session_name="$1"
|
||||
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c "
|
||||
local workspace="${2:-}"
|
||||
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c "
|
||||
import sys, os, json
|
||||
name = os.environ['SESSION_NAME']
|
||||
ws = os.environ.get('TARGET_WS', '').strip()
|
||||
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_session') or 'default')
|
||||
print(s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default')
|
||||
sys.exit(0)
|
||||
fallback = os.environ.get('HERDR_SESSION_NAME', '')
|
||||
if not fallback or fallback == 'default':
|
||||
pwd = os.path.abspath(os.getcwd())
|
||||
fallback = 'mam-' + os.path.basename(pwd).lower().replace('_', '-')
|
||||
legacy = os.environ.get('HERDR_SERVER_NAME', '')
|
||||
if legacy and legacy != 'default':
|
||||
fallback = legacy
|
||||
if not fallback or fallback == 'default':
|
||||
if ws:
|
||||
# derive_workspace_slug Equivalent in python
|
||||
abs_ws = os.path.abspath(ws)
|
||||
parent = os.path.basename(os.path.dirname(abs_ws)) or 'workspace'
|
||||
work = os.path.basename(abs_ws) or 'root'
|
||||
if parent in ('/', '.'): parent = 'workspace'
|
||||
if work in ('/', '.'): work = 'root'
|
||||
slug = f'{parent}-{work}'.lower().replace('_', '-')
|
||||
import re
|
||||
slug = re.sub(r'[^a-zA-Z0-9-]', '', slug).lstrip('-')
|
||||
fallback = f'mam-{slug}' if slug else 'mam-ws'
|
||||
else:
|
||||
fallback = 'default'
|
||||
print('WARN: resolve_herdr_session called without workspace parameter for unregistered session; falling back to default', file=sys.stderr)
|
||||
print(fallback or 'default')
|
||||
"
|
||||
}
|
||||
@@ -575,24 +594,10 @@ resolve_herdr_workspace() {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_session_name <workspace> <agent>
|
||||
#
|
||||
# THE single source of truth for the herdr session name. Rule:
|
||||
# slug = the two trailing path components of the absolute workspace,
|
||||
# '_' -> '-', lowercased, joined with '-'
|
||||
# name = "<slug>-creator-<agent>"
|
||||
#
|
||||
# Workspace root 기준 상대 해석. 예:
|
||||
# $WORKSPACE_ROOT/landing_page/refer_landing_page + claude
|
||||
# -> landing-page-refer-landing-page-creator-claude
|
||||
#
|
||||
# Decision (REVIEW P0-A): the actual workspace basename (refer_landing_page)
|
||||
# IS included. The hand-written historical entry that dropped it
|
||||
# (lab-landing-page-creator-claude) was the bug, not the convention.
|
||||
# Every script and SKILL.md must use exactly this rule.
|
||||
# derive_workspace_slug <workspace>
|
||||
# ---------------------------------------------------------------------------
|
||||
derive_session_name() {
|
||||
local workspace="$1" agent="$2"
|
||||
derive_workspace_slug() {
|
||||
local workspace="${1:-$PWD}"
|
||||
local abs parent work slug
|
||||
abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
||||
parent="$(basename "$(dirname "$abs")" 2>/dev/null || echo "")"
|
||||
@@ -609,7 +614,22 @@ derive_session_name() {
|
||||
if [ -z "$slug" ]; then
|
||||
slug="ws"
|
||||
fi
|
||||
printf '%s-creator-%s' "$slug" "$agent"
|
||||
printf 'mam-%s' "$slug"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_session_name <workspace> <agent>
|
||||
# ---------------------------------------------------------------------------
|
||||
derive_session_name() {
|
||||
local workspace="${1:-$PWD}" agent="${2:-}"
|
||||
local base_slug
|
||||
base_slug="$(derive_workspace_slug "$workspace")"
|
||||
local slug="${base_slug#mam-}"
|
||||
if [ -n "$agent" ]; then
|
||||
printf '%s-creator-%s' "$slug" "$agent"
|
||||
else
|
||||
printf '%s-creator-' "$slug"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1494,10 +1514,7 @@ start_watchdog() {
|
||||
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
|
||||
wait_for_tui_ready() {
|
||||
local sess="$1" agent="$2"
|
||||
local local_herdr="herdr" i
|
||||
if [ -n "${HERDR_SERVER_NAME:-}" ] && [ "$HERDR_SERVER_NAME" != "default" ]; then
|
||||
local_herdr="herdr -L $HERDR_SERVER_NAME"
|
||||
fi
|
||||
local i
|
||||
|
||||
for i in {1..30}; do
|
||||
if _pane_dialog_open "$sess"; then
|
||||
|
||||
@@ -73,7 +73,7 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
|
||||
if [ -n "$HERDR_SERVER_OPT" ]; then
|
||||
export HERDR_SERVER_NAME="$HERDR_SERVER_OPT"
|
||||
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
|
||||
fi
|
||||
|
||||
# Preflight
|
||||
@@ -127,9 +127,9 @@ fi
|
||||
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
|
||||
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
|
||||
|
||||
ws_slug="$(derive_session_name "$WORKSPACE" "$AGENT" | sed 's/-creator-.*//')"
|
||||
ws_slug="$(derive_workspace_slug "$WORKSPACE")"
|
||||
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
|
||||
export HERDR_SESSION_NAME="mam-$ws_slug"
|
||||
export HERDR_SESSION_NAME="$ws_slug"
|
||||
fi
|
||||
|
||||
# Resolve absolute path of the agent command to prevent herdr PATH inheritance issues (especially on macOS)
|
||||
@@ -151,8 +151,8 @@ case "$AGENT" in
|
||||
esac
|
||||
|
||||
spawn() {
|
||||
if [ -z "${HERDR_SERVER_NAME:-}" ] || [ "$HERDR_SERVER_NAME" = "default" ]; then
|
||||
export HERDR_SERVER_NAME="mam-$ws_slug"
|
||||
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
|
||||
export HERDR_SESSION_NAME="$ws_slug"
|
||||
fi
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
@@ -160,11 +160,11 @@ spawn() {
|
||||
nohup "$WRAPPER" >/dev/null 2>&1 &
|
||||
disown
|
||||
else
|
||||
HERDR_SERVER_NAME="$HERDR_SERVER_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
|
||||
fi
|
||||
;;
|
||||
agy|hermes|cline)
|
||||
HERDR_SERVER_NAME="$HERDR_SERVER_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
|
||||
;;
|
||||
*) echo "ERROR: --agent must be claude, agy, hermes or cline, got: $AGENT" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -187,8 +187,8 @@ cleanup_herdr_on_error() {
|
||||
}
|
||||
trap cleanup_herdr_on_error EXIT
|
||||
|
||||
RESOLVED_SERVER="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||
export HERDR_SERVER_NAME="${HERDR_SERVER_NAME:-$RESOLVED_SERVER}"
|
||||
RESOLVED_SERVER="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")"
|
||||
export HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-$RESOLVED_SERVER}"
|
||||
|
||||
# TUI 준비 대기
|
||||
if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
|
||||
@@ -209,7 +209,7 @@ NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
# 시작 명령
|
||||
# NOTE: this must match what `spawn()` actually ran above — env-var-driven
|
||||
# herdr server shim (HERDR_SERVER_NAME picked up by the lib.sh shim).
|
||||
START_CMD="HERDR_SERVER_NAME=${HERDR_SERVER_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
START_CMD="HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
|
||||
# If --onboard is specified, automatically build the onboarding prompt
|
||||
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then
|
||||
@@ -283,6 +283,7 @@ entry = {
|
||||
'herdr_session_created_at': os.environ['NOW_ISO'],
|
||||
'herdr_session_epoch': int(epoch) if epoch.isdigit() else 0,
|
||||
'herdr_session': server_name,
|
||||
'herdr_server': server_name,
|
||||
'delegate_job_id': os.environ.get('DELEGATE_JOB_ID', '') or None,
|
||||
'pane': {
|
||||
'index': 0,
|
||||
|
||||
@@ -455,7 +455,7 @@ run_agent() {
|
||||
# the caller having exported HERDR_SERVER_NAME by hand. This is what lets
|
||||
# delegation reach an agent living in an isolated herdr session (e.g. one
|
||||
# created with --herdr-server) instead of silently looking in "default".
|
||||
export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$sess")"
|
||||
export HERDR_SESSION_NAME="$(resolve_herdr_workspace "$sess" "$WORKDIR")"
|
||||
|
||||
if ! herdr has-session -t "$sess" 2>/dev/null; then
|
||||
echo "ERROR: 에이전트 세션 '$sess'이 존재하지 않습니다. 작업을 위임하기 전에 먼저 에이전트 세션을 기동해 주세요." >&2
|
||||
@@ -523,9 +523,9 @@ else:
|
||||
|
||||
# NOTE: `herdr session attach` operates on whole herdr *sessions* (server
|
||||
# instances), not an individual agent by its MAM name — `agent attach` is
|
||||
# the real command for that. HERDR_SERVER_NAME is inlined so the printed
|
||||
# the real command for that. HERDR_SESSION_NAME is inlined so the printed
|
||||
# command is copy-pasteable in a fresh shell that hasn't sourced lib.sh.
|
||||
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SERVER_NAME=$HERDR_SERVER_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)"
|
||||
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)"
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
|
||||
+597
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env bash
|
||||
# multi-agent-mux-delegate-job — user-facing orchestrator for the multi-agent-mux-delegate-job skill.
|
||||
#
|
||||
# Subcommands:
|
||||
# submit register a job, start the subscriber FIRST, then run the agent,
|
||||
# then (optionally) run a validation script.
|
||||
# status show one job record.
|
||||
# list list all jobs.
|
||||
# verify run a user-supplied --validate script against a job's artifacts.
|
||||
# wait block until all running/pending jobs reach a terminal state.
|
||||
#
|
||||
# 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`/`herdr` are missing — it prints what it would run.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Load local env file (.mam.env preferred, .env fallback) from workspace root or explicit MAM_ENV_FILE
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TARGET_ENV="${MAM_ENV_FILE:-}"
|
||||
if [[ -z "$TARGET_ENV" ]]; then
|
||||
if [[ -f "$REPO_ROOT/.mam.env" ]]; then
|
||||
TARGET_ENV="$REPO_ROOT/.mam.env"
|
||||
elif [[ -f "$REPO_ROOT/.env" ]]; then
|
||||
TARGET_ENV="$REPO_ROOT/.env"
|
||||
echo "WARNING: Loading deprecated config file '$TARGET_ENV'. Please migrate to '.mam.env'." >&2
|
||||
elif [[ -f .mam.env ]]; then
|
||||
TARGET_ENV=".mam.env"
|
||||
echo "WARNING: Loading config from cwd relative path '$TARGET_ENV'." >&2
|
||||
elif [[ -f .env ]]; then
|
||||
TARGET_ENV=".env"
|
||||
echo "WARNING: Loading deprecated config from cwd relative path '$TARGET_ENV'. Please migrate to '.mam.env'." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$TARGET_ENV" && -f "$TARGET_ENV" ]]; then
|
||||
set -a; source "$TARGET_ENV"; set +a
|
||||
fi
|
||||
|
||||
# Source EARLY (before any herdr usage in run_agent) — this is what turns
|
||||
# plain `herdr` into the tmux-compat shim (herdr() function) and provides
|
||||
# resolve_herdr_workspace/send_keys_safe. Sourcing it late meant the
|
||||
# has-session pre-flight check below used to hit the real herdr binary with
|
||||
# a nonexistent subcommand and always fail.
|
||||
source "$SCRIPT_DIR/../lib.sh"
|
||||
|
||||
# Pick an interpreter: prefer a project .venv, else python3.
|
||||
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
|
||||
py_bin="$(pwd)/.venv/bin/python"
|
||||
else
|
||||
py_bin="python3"
|
||||
fi
|
||||
if ! "$py_bin" -c "import paho.mqtt" 2>/dev/null; then
|
||||
echo "ERROR: paho-mqtt package is missing for $py_bin." >&2
|
||||
echo " Please create a virtual environment and install it:" >&2
|
||||
echo " python3 -m venv .venv && .venv/bin/pip install -r \"$SCRIPT_DIR/requirements.txt\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$py_bin"
|
||||
}
|
||||
|
||||
REGISTRY_DIR_DEFAULT=".mam/jobs"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
multi-agent-mux-delegate-job <command> [options]
|
||||
|
||||
submit --agent <name> --prompt <text> [--workdir <dir>] [--agent-session <label>]
|
||||
[--timeout <sec>] [--idle-timeout <sec>] [--validate <script>]
|
||||
[--registry-dir <dir>] [--dry-run] [--role <role_name>]
|
||||
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
|
||||
[--reviewer-session <reviewer_session>] [--max-iterations <count>]
|
||||
[--counterpart-role <role_name>] [--strict-role-check]
|
||||
# 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>]
|
||||
wait [--job <id>] [--timeout <sec>] [--registry-dir <dir>]
|
||||
logs <job_id> | --list # persistent audit log (delegate_job_logs/)
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---- arg parsing helpers --------------------------------------------------
|
||||
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="herdr:hermes"; MAX_ITERATIONS=5
|
||||
DEFAULT_COUNTERPART_ROLE="Reviewer"
|
||||
COUNTERPART_ROLE="$DEFAULT_COUNTERPART_ROLE"
|
||||
STRICT_ROLE_CHECK=0
|
||||
ROLE_ALIASES_JSON='{"worker": ["worker", "creator"], "planner": ["planner"], "reviewer": ["reviewer"]}'
|
||||
COUNTERPART_ROLE_EXPLICIT=0
|
||||
|
||||
parse_opts() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--agent) AGENT="$2"; shift 2;;
|
||||
--prompt) PROMPT="$2"; shift 2;;
|
||||
--workdir) WORKDIR="$2"; shift 2;;
|
||||
--agent-session) AGENT_SESSION="$2"; shift 2;;
|
||||
--timeout) TIMEOUT="$2"; shift 2;;
|
||||
--idle-timeout) IDLE_TIMEOUT="$2"; shift 2;;
|
||||
--validate) VALIDATE="$2"; shift 2;;
|
||||
--job) JOB_ID="$2"; shift 2;;
|
||||
--registry-dir) REGISTRY_DIR="$2"; shift 2;;
|
||||
--dry-run) DRY_RUN=1; shift;;
|
||||
--role) DELEGATE_ROLE="$2"; shift 2;;
|
||||
--type) TYPE="$2"; shift 2;;
|
||||
--reviewer) REVIEWER="$2"; shift 2;;
|
||||
--reviewer-session) REVIEWER_SESSION="$2"; shift 2;;
|
||||
--max-iterations) MAX_ITERATIONS="$2"; shift 2;;
|
||||
--counterpart-role) COUNTERPART_ROLE="$2"; COUNTERPART_ROLE_EXPLICIT=1; shift 2;;
|
||||
--strict-role-check) STRICT_ROLE_CHECK=1; shift;;
|
||||
*) echo "unknown option: $1" >&2; usage; exit 1;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
cmd_submit() {
|
||||
parse_opts "$@"
|
||||
[[ -n "$PROMPT" ]] || { echo "submit requires --prompt" >&2; exit 1; }
|
||||
PY="$(pick_python)"
|
||||
cd "$WORKDIR"
|
||||
mkdir -p "$REGISTRY_DIR"
|
||||
|
||||
# 1) register job (prints the new job id)
|
||||
JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \
|
||||
--prompt "$PROMPT" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \
|
||||
--timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
|
||||
--job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \
|
||||
--max-iterations "$MAX_ITERATIONS")"
|
||||
echo "registered job: $JOB_ID"
|
||||
|
||||
# 1-1) Provision job directory and write direct brief.md (MAM Job Restructuring)
|
||||
local job_dir="$REGISTRY_DIR/$JOB_ID"
|
||||
if [[ "$DRY_RUN" != "1" ]]; then
|
||||
mkdir -p "$job_dir"
|
||||
cat <<EOF > "$job_dir/brief.md"
|
||||
# 📋 Brief: Job $JOB_ID Delegation
|
||||
|
||||
- **Job ID**: $JOB_ID
|
||||
- **Target Agent**: $AGENT (session: $AGENT_SESSION)
|
||||
- **Role**: $DELEGATE_ROLE
|
||||
- **Timeout**: $TIMEOUT s (Idle: $IDLE_TIMEOUT s)
|
||||
- **Output Report Path**: .mam/jobs/$JOB_ID/$AGENT-reports/report-final.md
|
||||
|
||||
## 🔎 Task Description
|
||||
$PROMPT
|
||||
EOF
|
||||
echo "provisioned job directory: $job_dir"
|
||||
fi
|
||||
|
||||
if [[ "$TYPE" == "direct" ]]; then
|
||||
# 2) START THE SUBSCRIBER FIRST (ordering dependency — MQTT does not queue
|
||||
# non-retained messages for absent subscribers).
|
||||
local logf="$REGISTRY_DIR/$JOB_ID.subscriber.out"
|
||||
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
||||
--job "$JOB_ID" --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
|
||||
>"$logf" 2>&1 &
|
||||
local sub_pid=$!
|
||||
echo "subscriber pid: $sub_pid (log: $logf)"
|
||||
# Wait for the subscriber to CONNACK + SUBSCRIBE (reactive handshake)
|
||||
local sub_ready=0
|
||||
for ((i = 0; i < 25; i++)); do
|
||||
if kill -0 "$sub_pid" 2>/dev/null; then
|
||||
if grep -q '^SUBSCRIBED ' "$logf" 2>/dev/null; then
|
||||
sub_ready=1
|
||||
break
|
||||
fi
|
||||
else
|
||||
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
|
||||
if [ "$sub_ready" -ne 1 ]; then
|
||||
echo "WARNING: subscriber subscribe handshake timed out — falling back to proceed" >&2
|
||||
fi
|
||||
|
||||
# 3) run the agent (or print the command for dry-run / missing binary)
|
||||
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID"
|
||||
# NOTE: the agent MUST use --job "$JOB_ID" (the one we just minted). Hard-coding
|
||||
# an id from an earlier session is the #1 reason a delegated job sits idle and
|
||||
# times out (see SKILL.md "Wrong job_id propagated to the agent"). We make the
|
||||
# freshness explicit in the instruction header.
|
||||
local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started', success='$pub --event completed --detail <summary>', error='$pub --event error --detail <reason>'."
|
||||
|
||||
run_agent "$JOB_ID" "$instructions"
|
||||
|
||||
# 4) optional validation hook
|
||||
if [[ -n "$VALIDATE" ]]; then
|
||||
echo "running validation: $VALIDATE"
|
||||
if JOB_ID="$JOB_ID" REGISTRY_DIR="$REGISTRY_DIR" bash "$VALIDATE"; then
|
||||
echo "validation: PASS"
|
||||
else
|
||||
local rc=$?
|
||||
echo "validation: FAIL (exit $rc)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
# In dry-run we never started a real subscriber (the wrapper short-circuits
|
||||
# before launching one), but the wait below would still try to join the
|
||||
# background sub_pid from cmd_submit. Skip both the wait and the subscriber
|
||||
# log dump; the user just wants to see the instruction that would have run.
|
||||
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root_dry/$JOB_ID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
wait "$sub_pid" || true
|
||||
echo "subscriber output:"; cat "$logf" || true
|
||||
|
||||
# Last stdout line: the persistent audit-log dir for this job (see SKILL.md
|
||||
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
|
||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root/$JOB_ID"
|
||||
else
|
||||
# Implement loop/discuss orchestrator
|
||||
local iteration=1
|
||||
local current_prompt="$PROMPT"
|
||||
local current_session="$AGENT_SESSION"
|
||||
local _phase="worker"
|
||||
local display_role="$DELEGATE_ROLE"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
echo "[dry-run] orchestrator loop would start for job: $JOB_ID type: $TYPE"
|
||||
echo "worker session: $AGENT_SESSION, reviewer session: $REVIEWER_SESSION"
|
||||
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root_dry/$JOB_ID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
while true; do
|
||||
echo "=================================================="
|
||||
echo "Iteration $iteration - Role: $display_role"
|
||||
echo "Session: $current_session"
|
||||
echo "=================================================="
|
||||
|
||||
# 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#herdr:}"
|
||||
if [[ "$DRY_RUN" != "1" ]]; then
|
||||
mkdir -p "$job_dir"
|
||||
cat <<EOF > "$job_dir/brief.md"
|
||||
# 📋 Brief: Job $JOB_ID Delegation (Iteration $iteration)
|
||||
|
||||
- **Job ID**: $JOB_ID
|
||||
- **Target Agent/Session**: $current_session
|
||||
- **Role**: $display_role
|
||||
- **Iteration**: $iteration
|
||||
- **Output Report Path**: .mam/jobs/$JOB_ID/${clean_session}-reports/report-final.md
|
||||
|
||||
## 🔎 Task Description
|
||||
$current_prompt
|
||||
EOF
|
||||
echo "provisioned iteration brief: $job_dir/brief.md"
|
||||
fi
|
||||
|
||||
# Update job details in registry
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" update \
|
||||
--job "$JOB_ID" \
|
||||
--agent-session "$current_session" \
|
||||
--prompt "$current_prompt" \
|
||||
--iteration "$iteration" \
|
||||
--role "$display_role" \
|
||||
--status "pending"
|
||||
|
||||
# Start subscriber
|
||||
local logf="$REGISTRY_DIR/${JOB_ID}.iter_${iteration}_${display_role}.subscriber.out"
|
||||
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
||||
--job "$JOB_ID" --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
|
||||
>"$logf" 2>&1 &
|
||||
local sub_pid=$!
|
||||
echo "subscriber pid: $sub_pid (log: $logf)"
|
||||
# Wait for the subscriber to CONNACK + SUBSCRIBE (reactive handshake)
|
||||
local sub_ready=0
|
||||
for ((i = 0; i < 25; i++)); do
|
||||
if kill -0 "$sub_pid" 2>/dev/null; then
|
||||
if grep -q '^SUBSCRIBED ' "$logf" 2>/dev/null; then
|
||||
sub_ready=1
|
||||
break
|
||||
fi
|
||||
else
|
||||
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
|
||||
if [ "$sub_ready" -ne 1 ]; then
|
||||
echo "WARNING: subscriber subscribe handshake timed out — falling back to proceed" >&2
|
||||
fi
|
||||
|
||||
# Format instruction block
|
||||
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID"
|
||||
local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths for iteration $iteration are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started', success='$pub --event completed --detail <summary>', error='$pub --event error --detail <reason>'."
|
||||
|
||||
# Trigger agent
|
||||
local force_warn_only=0
|
||||
if [[ "$_phase" == "reviewer" && "$COUNTERPART_ROLE_EXPLICIT" -eq 1 \
|
||||
&& "${COUNTERPART_ROLE,,}" != "${DEFAULT_COUNTERPART_ROLE,,}" ]]; then
|
||||
force_warn_only=1
|
||||
fi
|
||||
run_agent "$JOB_ID" "$instructions" "$current_session" "$force_warn_only"
|
||||
|
||||
# Wait for subscriber
|
||||
local sub_rc=0
|
||||
wait "$sub_pid" || sub_rc=$?
|
||||
echo "subscriber output:"; cat "$logf" || true
|
||||
|
||||
# Check job status based on subscriber exit code
|
||||
local job_status="running"
|
||||
if [[ $sub_rc -eq 0 ]]; then
|
||||
job_status="completed"
|
||||
elif [[ $sub_rc -eq 1 ]]; then
|
||||
job_status="error"
|
||||
else
|
||||
job_status="timeout"
|
||||
fi
|
||||
|
||||
echo "Job role $display_role finished with status: $job_status"
|
||||
|
||||
# Retrieve feedback from the last event
|
||||
local feedback
|
||||
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
|
||||
echo "Feedback/Detail: $feedback"
|
||||
|
||||
if [[ "$_phase" == "worker" ]]; then
|
||||
if [[ "$job_status" != "completed" ]]; then
|
||||
echo "Worker did not complete successfully (status: $job_status). Terminating workflow."
|
||||
break
|
||||
fi
|
||||
|
||||
# Worker completed successfully, now switch to reviewer
|
||||
_phase="reviewer"
|
||||
display_role="$COUNTERPART_ROLE"
|
||||
current_session="$REVIEWER_SESSION"
|
||||
# Build reviewer prompt based on type
|
||||
if [[ "$TYPE" == "loop" ]]; then
|
||||
current_prompt="Review the changes/artifacts generated for job $JOB_ID. Check if they meet the requirements. If correct, publish completed event with 'PASS'. If there are issues, publish error event with detailed feedback/nits. CRITICAL: When raising issues or giving a review, you MUST include the exact reason for the issue and a clear direction for improvement (문제 제시에 대한 이유와 확실한 개선 방향을 반드시 포함해야 합니다)."
|
||||
elif [[ "$TYPE" == "discuss" ]]; then
|
||||
current_prompt="Read draft/documents generated for job $JOB_ID. Review the feasibility and content. Write your feedback/objections. If you agree with the plan, reply with 'AGREE'."
|
||||
fi
|
||||
else
|
||||
if [[ "$job_status" != "completed" ]]; then
|
||||
echo "Reviewer did not complete successfully (status: $job_status). Terminating workflow."
|
||||
break
|
||||
fi
|
||||
|
||||
# Reviewer finished. Check if pass/agree
|
||||
local success=0
|
||||
if [[ "$TYPE" == "loop" ]]; then
|
||||
if [[ "${feedback,,}" == *"pass"* ]]; then
|
||||
success=1
|
||||
fi
|
||||
elif [[ "$TYPE" == "discuss" ]]; then
|
||||
if [[ "${feedback,,}" == *"agree"* ]]; then
|
||||
success=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$success" == "1" ]]; then
|
||||
echo "Reviewer approved the work. Finalizing job as completed."
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "completed"
|
||||
break
|
||||
else
|
||||
# Reviewer rejected/provided feedback. Increment & check max iterations
|
||||
if [[ $iteration -ge $MAX_ITERATIONS ]]; then
|
||||
echo "Max iterations ($MAX_ITERATIONS) reached without approval. Terminating workflow."
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "error"
|
||||
break
|
||||
fi
|
||||
|
||||
iteration=$((iteration + 1))
|
||||
_phase="worker"
|
||||
display_role="$DELEGATE_ROLE"
|
||||
current_session="$AGENT_SESSION"
|
||||
current_prompt="The reviewer provided the following feedback for job $JOB_ID: $feedback. Please modify the code/artifacts to address these comments. CRITICAL: As the Developer Team Leader, you must thoroughly review the suggested modifications, verify their validity, adopt/implement them if valid, and if you judge any recommendation to be invalid, do NOT implement it but instead explain your reasons clearly in your response and send it back to the reviewer (수정안을 최대한 꼼꼼히 검토하여 타당성을 검증하고, 타당하다면 수렴하여 수정을 진행하되, 타당하지 않다고 판단되는 부분이 있다면 그 이유를 명확히 밝혀 리뷰어에게 전달하십시오)."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 4) optional validation hook
|
||||
if [[ -n "$VALIDATE" ]]; then
|
||||
echo "running validation: $VALIDATE"
|
||||
if JOB_ID="$JOB_ID" REGISTRY_DIR="$REGISTRY_DIR" bash "$VALIDATE"; then
|
||||
echo "validation: PASS"
|
||||
else
|
||||
local rc=$?
|
||||
echo "validation: FAIL (exit $rc)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Last stdout line: the persistent audit-log dir
|
||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root/$JOB_ID"
|
||||
fi
|
||||
}
|
||||
|
||||
run_agent() {
|
||||
local job_id="$1"; local instructions="$2"; local target_session="${3:-$AGENT_SESSION}"; local force_warn_only="${4:-0}"
|
||||
# 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
|
||||
# `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 `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#herdr:}"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
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 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
|
||||
|
||||
# Auto-resolve isolation the same way resume/stop/create do — don't rely on
|
||||
# the caller having exported HERDR_SERVER_NAME by hand. This is what lets
|
||||
# delegation reach an agent living in an isolated herdr session (e.g. one
|
||||
# created with --herdr-server) instead of silently looking in "default".
|
||||
export HERDR_SESSION_NAME="$(resolve_herdr_workspace "$sess" "$WORKDIR")"
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
# Check role suitability
|
||||
local sess_role job_role
|
||||
sess_role=$(SESS_NAME="$sess" MAM_STATE_JSON="$(load_state_json)" "$PY" -c "
|
||||
import os, json
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
name = os.environ.get('SESS_NAME')
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == name:
|
||||
print(s.get('role', ''))
|
||||
break
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
job_role=$("$PY" -c "
|
||||
import json
|
||||
try:
|
||||
with open('$REGISTRY_DIR/$job_id.json') as f:
|
||||
print(json.load(f).get('role', ''))
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$job_role" && -n "$sess_role" ]]; then
|
||||
local check_result
|
||||
check_result=$(JOB_ROLE="$job_role" SESS_ROLE="$sess_role" ROLE_ALIASES_JSON="$ROLE_ALIASES_JSON" "$PY" -c "
|
||||
import os, json
|
||||
job = os.environ.get('JOB_ROLE', '').lower()
|
||||
sess = os.environ.get('SESS_ROLE', '').lower()
|
||||
aliases = json.loads(os.environ.get('ROLE_ALIASES_JSON', '{}'))
|
||||
candidates = aliases.get(job, [job])
|
||||
if any(c in sess for c in candidates):
|
||||
print('OK')
|
||||
else:
|
||||
print('MISMATCH')
|
||||
" 2>/dev/null || echo "OK")
|
||||
|
||||
if [[ "$check_result" == "MISMATCH" ]]; then
|
||||
local mismatch_msg="Target session '$sess' has role '$sess_role' which does not match job role '$job_role'."
|
||||
if [[ "$STRICT_ROLE_CHECK" -eq 1 && "$force_warn_only" -ne 1 ]]; then
|
||||
echo "ERROR: role suitability mismatch. $mismatch_msg" >&2
|
||||
return 1
|
||||
else
|
||||
echo "WARNING: $mismatch_msg" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Before launching the agent, set up error trap to publish error event
|
||||
if [ -n "${job_id:-}" ] && [ -n "${PY:-}" ]; then
|
||||
pub_script="$SCRIPT_DIR/scripts/publish_event.py"
|
||||
trap "rc=\$?; if [ \$rc -ne 0 ]; then \"$PY\" \"$pub_script\" --job '$job_id' --event error --detail 'agent bootstrap failed (exit '\$rc')'; fi" EXIT
|
||||
fi
|
||||
|
||||
echo "살아있는 에이전트 세션 '$sess'에 작업을 위임합니다..."
|
||||
if ! send_keys_safe "$sess" "$instructions" "$job_id"; then
|
||||
echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess' (프롬프트 잠금 의심)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# NOTE: `herdr session attach` operates on whole herdr *sessions* (server
|
||||
# instances), not an individual agent by its MAM name — `agent attach` is
|
||||
# the real command for that. HERDR_SESSION_NAME is inlined so the printed
|
||||
# command is copy-pasteable in a fresh shell that hasn't sourced lib.sh.
|
||||
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)"
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
parse_opts "$@"
|
||||
[[ -n "$JOB_ID" ]] || { echo "status requires --job" >&2; exit 1; }
|
||||
PY="$(pick_python)"
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get --job "$JOB_ID"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
parse_opts "$@"
|
||||
PY="$(pick_python)"
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" list
|
||||
}
|
||||
|
||||
cmd_verify() {
|
||||
parse_opts "$@"
|
||||
[[ -n "$JOB_ID" ]] || { echo "verify requires --job" >&2; exit 1; }
|
||||
[[ -n "$VALIDATE" ]] || { echo "verify requires --validate <script>" >&2; exit 1; }
|
||||
echo "verifying job $JOB_ID with $VALIDATE"
|
||||
if JOB_ID="$JOB_ID" REGISTRY_DIR="$REGISTRY_DIR" bash "$VALIDATE"; then
|
||||
echo "verify: PASS (exit 0)"; exit 0
|
||||
else
|
||||
rc=$?; echo "verify: FAIL (exit $rc)"; exit "$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
# logs <job_id> | logs --list — delegates to registry.py's logs CLI, which
|
||||
# reads the persistent audit log under $DELEGATE_JOB_LOGS_DIR (or
|
||||
# <cwd>/delegate_job_logs). Run from your project dir so the default resolves.
|
||||
PY="$(pick_python)"
|
||||
if [[ "${1:-}" == "--list" ]]; then
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" logs --list
|
||||
else
|
||||
local jid="${1:-}"
|
||||
[[ -n "$jid" ]] || { echo "logs requires <job_id> or --list" >&2; exit 1; }
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" logs "$jid"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_wait() {
|
||||
parse_opts "$@"
|
||||
PY="$(pick_python)"
|
||||
if [[ -n "$JOB_ID" ]]; then
|
||||
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
||||
--job "$JOB_ID" --timeout "$TIMEOUT"
|
||||
else
|
||||
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
||||
--wait-any --timeout "$TIMEOUT"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local sub="${1:-}"; shift || true
|
||||
case "$sub" in
|
||||
submit) cmd_submit "$@";;
|
||||
status) cmd_status "$@";;
|
||||
list) cmd_list "$@";;
|
||||
verify) cmd_verify "$@";;
|
||||
wait) cmd_wait "$@";;
|
||||
logs) cmd_logs "$@";;
|
||||
""|-h|--help|help) usage;;
|
||||
*) echo "unknown command: $sub" >&2; usage; exit 1;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -354,12 +354,14 @@ actions = []
|
||||
herdr_sessions = []
|
||||
herdr_confirmed = True
|
||||
|
||||
# YAML 에 등록된 고유한 herdr_server 목록 수집 + 환경변수 HERDR_SERVER_NAME 포함
|
||||
# YAML 에 등록된 고유한 herdr_session 목록 수집 + 환경변수 HERDR_SESSION_NAME 포함
|
||||
unique_servers = {'default'}
|
||||
if 'HERDR_SERVER_NAME' in os.environ:
|
||||
if 'HERDR_SESSION_NAME' in os.environ:
|
||||
unique_servers.add(os.environ['HERDR_SESSION_NAME'])
|
||||
elif '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_workspace') or s.get('herdr_server') or 'default'
|
||||
srv = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
|
||||
unique_servers.add(srv)
|
||||
|
||||
try:
|
||||
@@ -438,7 +440,7 @@ if herdr_confirmed:
|
||||
# (없으면 herdr-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
|
||||
if s.get('status') in ('terminated', 'archived', 'stopped'):
|
||||
continue
|
||||
srv = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
|
||||
srv = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
|
||||
if (name, srv) not in alive_set:
|
||||
s['status'] = 'terminated'
|
||||
s['terminated_at'] = now_iso
|
||||
|
||||
@@ -76,7 +76,7 @@ if [ -z "$UUID" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||
export HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")"
|
||||
|
||||
# 2. If herdr is alive, attach. Done.
|
||||
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
|
||||
@@ -44,7 +44,7 @@ if [ -z "$UUID" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME")"
|
||||
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")"
|
||||
export HERDR_SESSION_NAME
|
||||
|
||||
# 2. If herdr is alive, print warning or attach.
|
||||
|
||||
@@ -37,7 +37,7 @@ done
|
||||
[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; }
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
||||
|
||||
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME")"
|
||||
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "${WORKSPACE:-}")"
|
||||
export HERDR_SESSION_NAME
|
||||
|
||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
|
||||
|
||||
@@ -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_workspace') or s.get('herdr_server') or 'default'
|
||||
server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') 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_workspace') or s.get('herdr_server') or 'default'
|
||||
server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
|
||||
status = s.get('status', '?')
|
||||
herdr = 'alive' if f"{name}|{server}" in alive else 'dead'
|
||||
cmd = (s.get('pane') or {}).get('cmd', '?')
|
||||
|
||||
@@ -82,8 +82,8 @@ if [ "$PURGE" = "1" ]; then
|
||||
trap 'rm -f "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"' EXIT
|
||||
fi
|
||||
|
||||
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||
export HERDR_SERVER_NAME
|
||||
HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME" "${WORKSPACE:-$WORKSPACE_ROOT}")"
|
||||
export HERDR_SESSION_NAME
|
||||
|
||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F)
|
||||
if [ -z "$AGENT" ]; then
|
||||
|
||||
@@ -12,6 +12,8 @@ def run_lib_func(mam_sandbox, func_name, *args, env=None):
|
||||
lib_path = mam_sandbox / "skills" / "lib.sh"
|
||||
cmd_str = f"source {lib_path} && {func_name} " + " ".join(shlex.quote(str(a)) for a in args)
|
||||
run_env = dict(os.environ)
|
||||
run_env.pop("HERDR_SESSION_NAME", None)
|
||||
run_env.pop("HERDR_SERVER_NAME", None)
|
||||
if env:
|
||||
run_env.update(env)
|
||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, env=run_env)
|
||||
@@ -111,10 +113,14 @@ def test_resume_resolve_herdr_session_default(mam_sandbox):
|
||||
assert res.stdout.strip() != ""
|
||||
|
||||
def test_resume_resolve_herdr_session_env(mam_sandbox):
|
||||
"""Test resolve_herdr_workspace fallback to HERDR_SERVER_NAME env var."""
|
||||
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SERVER_NAME": "custom_server"})
|
||||
"""Test resolve_herdr_workspace fallback to HERDR_SESSION_NAME or HERDR_SERVER_NAME env var."""
|
||||
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SESSION_NAME": "custom_session"})
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == "custom_server"
|
||||
assert res.stdout.strip() == "custom_session"
|
||||
|
||||
res_legacy = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SERVER_NAME": "custom_server"})
|
||||
assert res_legacy.returncode == 0
|
||||
assert res_legacy.stdout.strip() == "custom_server"
|
||||
|
||||
def test_resume_find_workspace_uuid_empty(mam_sandbox):
|
||||
"""Test find_workspace_uuid returns empty string for non-existent workspace."""
|
||||
|
||||
@@ -24,10 +24,13 @@ class TestWorkspaceScope(unittest.TestCase):
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
lib_sh = os.path.join(repo_root, ".agents", "skills", "lib.sh")
|
||||
|
||||
cmd = f"source {lib_sh} && cd {self.ws_dir} && resolve_herdr_session 'test-session'"
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
|
||||
cmd = f"source {lib_sh} && env -u HERDR_SESSION_NAME -u HERDR_SERVER_NAME bash -c 'source {lib_sh} && resolve_herdr_session \"test-session\" \"{self.ws_dir}\"'"
|
||||
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0, f"Command failed: {res.stderr}")
|
||||
self.assertEqual(res.stdout.strip(), "mam-my-project")
|
||||
parent = os.path.basename(os.path.dirname(os.path.abspath(self.ws_dir))).lower().replace('_', '-')
|
||||
work = os.path.basename(os.path.abspath(self.ws_dir)).lower().replace('_', '-')
|
||||
expected_slug = f"mam-{parent}-{work}"
|
||||
self.assertEqual(res.stdout.strip(), expected_slug)
|
||||
|
||||
def test_drift_b_cwd_gate(self):
|
||||
"""Verify that reconcile.sh drift-B refuses to auto-register sessions with foreign cwd."""
|
||||
|
||||
Reference in New Issue
Block a user