Round-2 review of952657e(reviewer-grok-01): R1's disk-heartbeat evidence comes from events.log, but publish_event appended to it only after with_retry(_publish_once) returned - after the broker ACK or all of its retries. With the public broker at ~5 s per publish, an agent's heartbeat reached the disk after the grace window and the live job was closed as deadline_timeout (test_wrapper_idle_timeout_grace_accepts_disk_heartbeat failed 2/2 on952657ehere, as it did for the reviewer). - publish_event: new record_local_event appends the prepared payload to events.log right after prepare_event (validation and seq allocation first) and before the wire publish; record_outcome takes local_event_recorded=True so the line is not written twice, while the audit "published" entry and the status sync stay after the publish - DelegationService.publish_event: the same local-first order - wrapper comment and VERSIONS.md v4.4.2 item 6 now say the heartbeat is on disk before the publish; new-test count 21 - tests: the heartbeat is already in events.log when the publish starts (and is recorded once), and a publish failing on every retry leaves one local record with published=False in the audit; both fail on952657e. The wrapper disk-heartbeat test keeps the real publish_event path and now passes 3/3 at the same broker latency Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
981 lines
39 KiB
Bash
Executable File
981 lines
39 KiB
Bash
Executable File
#!/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_session/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
|
|
}
|
|
|
|
_to_lower() {
|
|
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
|
|
}
|
|
|
|
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
|
|
|
|
local START_TIME
|
|
START_TIME=$(date +%s)
|
|
|
|
if [[ "$TYPE" == "direct" ]]; then
|
|
# 1) Read dispatch token and attempt id for this dispatch
|
|
local _disp_tok=""
|
|
local _attempt_id=""
|
|
_disp_tok="$("$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$JOB_ID.json'); print(json.load(open(p)).get('dispatch_token','') or '') if os.path.exists(p) else print('')" 2>/dev/null || true)"
|
|
_attempt_id="$("$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$JOB_ID.json'); print(json.load(open(p)).get('attempt_id','') or '') if os.path.exists(p) else print('')" 2>/dev/null || true)"
|
|
local pub_extra=()
|
|
[ -n "$_disp_tok" ] && pub_extra+=(--expect-dispatch-token "$_disp_tok")
|
|
[ -n "$_attempt_id" ] && pub_extra+=(--attempt-id "$_attempt_id")
|
|
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID ${pub_extra[*]}"
|
|
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>'."
|
|
|
|
if [[ "$DRY_RUN" == "1" ]]; then
|
|
run_agent "$JOB_ID" "$instructions"
|
|
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root_dry/$JOB_ID"
|
|
return 0
|
|
fi
|
|
|
|
# 1) Preflight
|
|
if ! preflight_agent "$JOB_ID" "$AGENT_SESSION" 0; then
|
|
finalize_unfinished "$JOB_ID" "error" 1 "preflight"
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
exit 5
|
|
fi
|
|
|
|
# 2) TUI idle handshake
|
|
if [ "$AGENT" != "human" ]; then
|
|
local idle_rc=0
|
|
wait_for_agent_idle "$AGENT_SESSION" || idle_rc=$?
|
|
export _SKS_IDLE_RC="$idle_rc"
|
|
if [ $idle_rc -eq 1 ] || [ $idle_rc -eq 2 ]; then
|
|
finalize_unfinished "$JOB_ID" "error" "$idle_rc" "idle_wait"
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
exit 5
|
|
fi
|
|
fi
|
|
|
|
# 3) START THE SUBSCRIBER
|
|
local logf="$REGISTRY_DIR/$JOB_ID.subscriber.out"
|
|
local sub_extra_args=()
|
|
[ -n "$_disp_tok" ] && sub_extra_args+=(--expect-dispatch-token "$_disp_tok")
|
|
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
|
--job "$JOB_ID" --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
|
|
${sub_extra_args[@]+"${sub_extra_args[@]}"} \
|
|
>"$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
|
|
local sub_exit=0
|
|
wait "$sub_pid" 2>/dev/null || sub_exit=$?
|
|
if [ $sub_exit -eq 0 ] || [ $sub_exit -eq 3 ]; then
|
|
sub_ready=1
|
|
break
|
|
else
|
|
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
|
local mapped_st
|
|
mapped_st="$(_map_sub_rc "$sub_exit" "$JOB_ID")"
|
|
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
|
|
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
|
exit 0
|
|
fi
|
|
exit "$(_rc_for "$mapped_st")"
|
|
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
|
|
|
|
# 5) Inject agent
|
|
if ! inject_agent "$JOB_ID" "$instructions" "$AGENT_SESSION"; then
|
|
reap_subscriber "$sub_pid"
|
|
finalize_unfinished "$JOB_ID" "error" 1 "inject"
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
exit 1
|
|
fi
|
|
|
|
# 6) 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
|
|
|
|
local sub_rc=0
|
|
wait "$sub_pid" || sub_rc=$?
|
|
echo "subscriber output:"; cat "$logf" || true
|
|
|
|
local job_status
|
|
job_status="$(_map_sub_rc "$sub_rc" "$JOB_ID")"
|
|
|
|
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "direct"
|
|
job_status="${FINAL_JOB_STATUS:-$job_status}"
|
|
|
|
# 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"
|
|
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
|
exit 0
|
|
fi
|
|
exit "$(_rc_for "$job_status")"
|
|
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"
|
|
local attempt=0
|
|
local final_loop_rc=0
|
|
|
|
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:}"
|
|
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"
|
|
|
|
attempt=$((attempt + 1))
|
|
|
|
local force_warn_only=0
|
|
local cp_lower def_lower
|
|
cp_lower=$(_to_lower "$COUNTERPART_ROLE")
|
|
def_lower=$(_to_lower "$DEFAULT_COUNTERPART_ROLE")
|
|
if [[ "$_phase" == "reviewer" && "$COUNTERPART_ROLE_EXPLICIT" -eq 1 \
|
|
&& "$cp_lower" != "$def_lower" ]]; then
|
|
force_warn_only=1
|
|
fi
|
|
|
|
# Preflight
|
|
if ! preflight_agent "$JOB_ID" "$current_session" "$force_warn_only"; then
|
|
finalize_unfinished "$JOB_ID" "error" 1 "preflight"
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
exit 5
|
|
fi
|
|
|
|
# Idle wait
|
|
local cur_agent="$AGENT"
|
|
[ "$_phase" = "reviewer" ] && cur_agent="$REVIEWER"
|
|
if [ "$cur_agent" != "human" ]; then
|
|
local idle_rc=0
|
|
wait_for_agent_idle "$current_session" || idle_rc=$?
|
|
export _SKS_IDLE_RC="$idle_rc"
|
|
if [ $idle_rc -eq 1 ] || [ $idle_rc -eq 2 ]; then
|
|
finalize_unfinished "$JOB_ID" "error" "$idle_rc" "idle_wait"
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
exit 5
|
|
fi
|
|
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" \
|
|
--attempt-id "$attempt" \
|
|
--role "$display_role" \
|
|
--status "pending"
|
|
|
|
local _disp_tok=""
|
|
_disp_tok="$("$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$JOB_ID.json'); print(json.load(open(p)).get('dispatch_token','') or '') if os.path.exists(p) else print('')" 2>/dev/null || true)"
|
|
|
|
# Start subscriber
|
|
local logf="$REGISTRY_DIR/${JOB_ID}.iter_${iteration}_${display_role}.subscriber.out"
|
|
local loop_sub_args=(--attempt-id "$attempt")
|
|
[ -n "$_disp_tok" ] && loop_sub_args+=(--expect-dispatch-token "$_disp_tok")
|
|
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
|
--job "$JOB_ID" --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
|
|
"${loop_sub_args[@]}" \
|
|
>"$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
|
|
local sub_exit=0
|
|
wait "$sub_pid" 2>/dev/null || sub_exit=$?
|
|
if [ $sub_exit -eq 0 ] || [ $sub_exit -eq 3 ]; then
|
|
sub_ready=1
|
|
break
|
|
else
|
|
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
|
local mapped_st
|
|
mapped_st="$(_map_sub_rc "$sub_exit" "$JOB_ID")"
|
|
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
|
|
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
|
exit 0
|
|
fi
|
|
exit "$(_rc_for "$mapped_st")"
|
|
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 loop_pub_extra=(--attempt-id "$attempt")
|
|
[ -n "$_disp_tok" ] && loop_pub_extra+=(--expect-dispatch-token "$_disp_tok")
|
|
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID ${loop_pub_extra[*]}"
|
|
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
|
|
if ! inject_agent "$JOB_ID" "$instructions" "$current_session"; then
|
|
reap_subscriber "$sub_pid"
|
|
finalize_unfinished "$JOB_ID" "error" 1 "inject"
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
exit 1
|
|
fi
|
|
|
|
# Wait for subscriber
|
|
local sub_rc=0
|
|
wait "$sub_pid" || sub_rc=$?
|
|
echo "subscriber output:"; cat "$logf" || true
|
|
|
|
local job_status
|
|
job_status="$(_map_sub_rc "$sub_rc" "$JOB_ID")"
|
|
|
|
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."
|
|
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "worker" "$current_session"
|
|
job_status="${FINAL_JOB_STATUS:-$job_status}"
|
|
if [[ "$job_status" == "completed" ]]; then
|
|
echo "Worker completed during grace period. Continuing to reviewer."
|
|
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
|
|
else
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
|
exit 0
|
|
fi
|
|
exit "$(_rc_for "$job_status")"
|
|
fi
|
|
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."
|
|
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "reviewer" "$current_session"
|
|
job_status="${FINAL_JOB_STATUS:-$job_status}"
|
|
if [[ "$job_status" == "completed" ]]; then
|
|
echo "Reviewer completed during grace period. Continuing evaluation."
|
|
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
|
|
else
|
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
|
echo "$logs_root/$JOB_ID"
|
|
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
|
exit 0
|
|
fi
|
|
exit "$(_rc_for "$job_status")"
|
|
fi
|
|
fi
|
|
|
|
# Reviewer finished. Check if pass/agree
|
|
local success=0
|
|
local fb_lower
|
|
fb_lower=$(_to_lower "$feedback")
|
|
if [[ "$TYPE" == "loop" ]]; then
|
|
if [[ "$fb_lower" == *"pass"* ]]; then
|
|
success=1
|
|
fi
|
|
elif [[ "$TYPE" == "discuss" ]]; then
|
|
if [[ "$fb_lower" == *"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"
|
|
final_loop_rc=0
|
|
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"
|
|
final_loop_rc=1
|
|
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)"
|
|
final_loop_rc=$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"
|
|
exit "$final_loop_rc"
|
|
fi
|
|
}
|
|
|
|
reap_subscriber() {
|
|
if [ -n "${1:-}" ]; then
|
|
kill "$1" 2>/dev/null || true
|
|
wait "$1" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
_disk_status() {
|
|
local jid="$1"
|
|
"$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 advance past pending. Any other status means the job moved
|
|
# on - normally the agent's own `started` - so the prompt is treated as
|
|
# delivered. The status is not pinned to this dispatch and may come from an
|
|
# external writer; the terminal handling after this still keeps such an
|
|
# error/cancelled as it is rather than turning it into success.
|
|
_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 already advanced to '$st' in the registry; treating the prompt as delivered and continuing." >&2
|
|
return 0
|
|
fi
|
|
[ "$(date +%s)" -ge "$deadline" ] && return 1
|
|
sleep 1
|
|
done
|
|
}
|
|
|
|
# ISSUE-14: herdr's agent_status is blind for some agents (codex stays `idle`
|
|
# for the whole turn), so "still working" must also accept the pane's own busy
|
|
# token - the same evidence send_keys_safe uses to confirm delivery. Without it
|
|
# a codex review longer than the idle timeout was closed as deadline_timeout
|
|
# mid-turn and its eventual `completed` refused.
|
|
_agent_still_working() {
|
|
local sess="$1" target="$2"
|
|
[ "$(_sks_agent_status "$sess" "$target")" = "working" ] && return 0
|
|
_pane_tail "$sess" 8 2>/dev/null | grep -Fq "esc to interrupt"
|
|
}
|
|
|
|
# _within_deadline <deadline_epoch> <cmd...>
|
|
# Run a probe that can block (a herdr RPC, a pane capture) without letting it
|
|
# outlive a wall-clock deadline: once the clock reaches <deadline> the probe
|
|
# and its children are killed and the result is rc 124, i.e. "no evidence".
|
|
_within_deadline() {
|
|
local deadline="$1" pid
|
|
shift
|
|
"$@" &
|
|
pid=$!
|
|
while kill -0 "$pid" 2>/dev/null; do
|
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
|
pkill -P "$pid" 2>/dev/null || true
|
|
kill "$pid" 2>/dev/null || true
|
|
wait "$pid" 2>/dev/null || true
|
|
return 124
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
wait "$pid"
|
|
}
|
|
|
|
# ISSUE-14: agent-agnostic liveness. publish_event appends every event to the
|
|
# job's on-disk events.log BEFORE its wire publish (record_local_event), so a
|
|
# `progress` heartbeat the subscriber never saw (it had already idled out, or
|
|
# the broker dropped it) is visible here the moment it is sent - a slow or
|
|
# failing broker no longer delays it by its publish retries. An agent running
|
|
# its tests in a background shell looks idle to herdr and shows no busy
|
|
# token, yet keeps heartbeating - this is the only evidence that covers that
|
|
# case.
|
|
_recent_disk_heartbeat() {
|
|
local job_id="$1" window="$2" f="$REGISTRY_DIR/$job_id.events.log"
|
|
[ -f "$f" ] || return 1
|
|
"$PY" - "$f" "$window" <<'PYEOF'
|
|
import calendar, json, sys, time
|
|
path, window = sys.argv[1], float(sys.argv[2])
|
|
last = ""
|
|
with open(path) as fh:
|
|
for line in fh:
|
|
if line.strip():
|
|
last = line
|
|
try:
|
|
stamp = json.loads(last)["timestamp"]
|
|
seen = calendar.timegm(time.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ"))
|
|
except Exception:
|
|
sys.exit(1)
|
|
sys.exit(0 if time.time() - seen <= window else 1)
|
|
PYEOF
|
|
}
|
|
|
|
_map_sub_rc() {
|
|
local sub_rc="$1" job_id="$2"
|
|
local job_status="running"
|
|
if [[ $sub_rc -eq 0 ]]; then
|
|
job_status="completed"
|
|
elif [[ $sub_rc -eq 1 ]]; then
|
|
job_status="error"
|
|
elif [[ $sub_rc -eq 3 ]]; then
|
|
job_status="broker_unavailable"
|
|
local disk_st
|
|
disk_st="$(_disk_status "$job_id")"
|
|
if [[ "$disk_st" == "completed" || "$disk_st" == "error" || "$disk_st" == "cancelled" ]]; then
|
|
job_status="$disk_st"
|
|
fi
|
|
else
|
|
job_status="timeout"
|
|
fi
|
|
echo "$job_status"
|
|
}
|
|
|
|
_rc_for() {
|
|
case "$1" in
|
|
completed) echo 0 ;;
|
|
error) echo 1 ;;
|
|
timeout) echo 2 ;;
|
|
broker_unavailable) echo 3 ;;
|
|
cancelled) echo 5 ;;
|
|
*) echo 1 ;;
|
|
esac
|
|
}
|
|
|
|
finalize_unfinished() {
|
|
local job_id="$1" job_status="$2" sub_rc="$3" phase="${4:-}"
|
|
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
|
return 0
|
|
fi
|
|
if [ "$job_status" = "completed" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local cur_disk_st="$(_disk_status "$job_id")"
|
|
if [ "$cur_disk_st" = "completed" ] || [ "$cur_disk_st" = "error" ] || [ "$cur_disk_st" = "cancelled" ]; then
|
|
FINAL_JOB_STATUS="$cur_disk_st"
|
|
[ "$cur_disk_st" = "completed" ] && return 0
|
|
else
|
|
FINAL_JOB_STATUS="$job_status"
|
|
fi
|
|
# broker_unavailable: re-observe the disk at 3 s intervals until this
|
|
# dispatch's wall-clock deadline (START_TIME + TIMEOUT). The deadline is read
|
|
# off the clock on every pass; summing the sleeps alone ignores the time the
|
|
# disk probes themselves take and overruns TIMEOUT.
|
|
if [ "$job_status" = "broker_unavailable" ]; then
|
|
local deadline left disk_st
|
|
deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT ))
|
|
while :; do
|
|
left=$(( deadline - $(date +%s) ))
|
|
[ "$left" -le 0 ] && break
|
|
[ "$left" -gt 3 ] && left=3
|
|
sleep "$left"
|
|
disk_st="$(_disk_status "$job_id")"
|
|
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
|
|
job_status="$disk_st"
|
|
FINAL_JOB_STATUS="$disk_st"
|
|
[ "$disk_st" = "completed" ] && return 0
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# timeout: if subscriber timed out, check if agent is still actively working in herdr
|
|
if [ "$job_status" = "timeout" ]; then
|
|
local target_sess="${5:-${current_session:-${target_session:-$AGENT_SESSION}}}"
|
|
local sess="${target_sess#herdr:}"
|
|
local target_agent
|
|
target_agent=$(_sanitize_herdr_agent_name "$sess")
|
|
|
|
# ISSUE-14: the subscriber's idle clock only counts events it received over
|
|
# the wire. Keep re-observing the disk while liveness evidence keeps
|
|
# turning up - herdr `working`, the pane busy token, or a heartbeat in
|
|
# events.log at most IDLE_TIMEOUT old (see _recent_disk_heartbeat).
|
|
# Window policy: entering this grace opens one IDLE_TIMEOUT window and
|
|
# every sighting re-opens it from the time of that poll. An agent that
|
|
# leaves no evidence at all is therefore closed about 2 x IDLE_TIMEOUT
|
|
# after its last wire event (the subscriber's idle wait plus this window;
|
|
# it was IDLE_TIMEOUT before disk heartbeats counted), and heartbeats may
|
|
# be up to about 2 x IDLE_TIMEOUT apart, since one still counts until it
|
|
# is IDLE_TIMEOUT old. Evidence only ever extends the idle window, never
|
|
# this dispatch's wall clock: the loop ends at START_TIME + TIMEOUT read
|
|
# off the clock on every pass, and the herdr probe is cut off at that
|
|
# deadline too (_within_deadline).
|
|
local deadline evidence_until left disk_st
|
|
deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT ))
|
|
evidence_until=$(( $(date +%s) + IDLE_TIMEOUT ))
|
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
|
disk_st="$(_disk_status "$job_id")"
|
|
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
|
|
job_status="$disk_st"
|
|
FINAL_JOB_STATUS="$disk_st"
|
|
[ "$disk_st" = "completed" ] && return 0
|
|
break
|
|
fi
|
|
if { [ "$sess" != "human" ] && [ -n "$sess" ] && _within_deadline "$deadline" _agent_still_working "$sess" "$target_agent"; } \
|
|
|| _recent_disk_heartbeat "$job_id" "$IDLE_TIMEOUT"; then
|
|
evidence_until=$(( $(date +%s) + IDLE_TIMEOUT ))
|
|
elif [ "$(date +%s)" -ge "$evidence_until" ]; then
|
|
break
|
|
fi
|
|
left=$(( deadline - $(date +%s) ))
|
|
[ "$left" -le 0 ] && break
|
|
sleep 1
|
|
done
|
|
fi
|
|
|
|
local close_req="deadline_timeout"
|
|
local detail=""
|
|
if [ "$phase" = "preflight" ] || [ "$phase" = "idle_wait" ]; then
|
|
close_req="cancelled"
|
|
detail="session_busy"
|
|
fi
|
|
|
|
local close_args=(--job "$job_id" --request "$close_req")
|
|
[ -n "$detail" ] && close_args+=(--detail "$detail")
|
|
[ -n "${_disp_tok:-}" ] && close_args+=(--expect-dispatch-token "$_disp_tok")
|
|
|
|
local close_rc=0
|
|
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" close "${close_args[@]}" 2>&1 || close_rc=$?
|
|
|
|
# If close failed due to dispatch token mismatch (rc=4), suppress secondary event emission
|
|
# to prevent CAS/ABA bypass
|
|
if [ $close_rc -eq 4 ]; then
|
|
echo "finalize_unfinished: dispatch token mismatch (stale attempt); suppressing event emission" >&2
|
|
return 0
|
|
fi
|
|
|
|
# Check if job completed on disk (universal, for all TYPEs)
|
|
local cur_disk_st
|
|
cur_disk_st="$(_disk_status "$job_id")"
|
|
if [ "$cur_disk_st" = "completed" ]; then
|
|
FINAL_JOB_STATUS="completed"
|
|
return 0
|
|
fi
|
|
|
|
# direct path only: publish MQTT error/cancelled event (loop/discuss forbidden: O-1)
|
|
if [ "$TYPE" = "direct" ]; then
|
|
local disp_arg=()
|
|
[ -n "${_disp_tok:-}" ] && disp_arg=(--expect-dispatch-token "$_disp_tok")
|
|
if [ "$close_req" = "cancelled" ]; then
|
|
"$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$job_id" --event cancelled --detail "session_busy" ${disp_arg[@]+"${disp_arg[@]}"} 2>/dev/null || true
|
|
elif [ "$sub_rc" -ne 1 ] || [ "$phase" = "inject" ]; then
|
|
local err_detail="deadline_timeout"
|
|
[ "$phase" = "inject" ] && err_detail="agent_blocked"
|
|
"$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$job_id" --event error --detail "$err_detail" ${disp_arg[@]+"${disp_arg[@]}"} 2>/dev/null || true
|
|
fi
|
|
fi
|
|
}
|
|
|
|
preflight_agent() {
|
|
local job_id="$1" target_session="${2:-$AGENT_SESSION}" force_warn_only="${3:-0}"
|
|
if [ "$AGENT" = "human" ]; then
|
|
return 0
|
|
fi
|
|
local sess="${target_session#herdr:}"
|
|
|
|
# B-3: must probe for the real binary, not the herdr() function / shim wrapper.
|
|
if ! has_real_herdr; then
|
|
echo "ERROR: this skill requires herdr (interactive agent sessions)." >&2
|
|
echo " Ensure herdr is installed and executable." >&2
|
|
return 1
|
|
fi
|
|
|
|
export HERDR_SESSION_NAME="$(resolve_herdr_session "$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
|
|
return 0
|
|
}
|
|
|
|
inject_agent() {
|
|
local job_id="$1" instructions="$2" target_session="${3:-$AGENT_SESSION}"
|
|
if [ "$AGENT" = "human" ]; then
|
|
echo "[human agent] complete the task, then run publish_event.py --event completed"
|
|
return 0
|
|
fi
|
|
local sess="${target_session#herdr:}"
|
|
|
|
# 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\" --registry-dir '$REGISTRY_DIR' --job '$job_id' --event error --detail 'agent bootstrap failed (exit '\$rc')'; fi" EXIT
|
|
fi
|
|
|
|
echo "살아있는 에이전트 세션 '$sess'에 작업을 위임합니다..."
|
|
if ! send_keys_safe "$sess" "$instructions" "$job_id" && ! _inject_acknowledged "$job_id"; then
|
|
trap - EXIT
|
|
echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess' (프롬프트 잠금 의심)" >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)"
|
|
trap - EXIT
|
|
return 0
|
|
}
|
|
|
|
run_agent() {
|
|
local job_id="$1" instructions="$2" target_session="${3:-$AGENT_SESSION}" force_warn_only="${4:-0}"
|
|
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 0
|
|
fi
|
|
preflight_agent "$job_id" "$target_session" "$force_warn_only" || return $?
|
|
inject_agent "$job_id" "$instructions" "$target_session" || return $?
|
|
}
|
|
|
|
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 "$@"
|