Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59be1c8920 | ||
|
|
f26952ccc3 | ||
|
|
14d11bf062 | ||
|
|
f5cadd98c0 | ||
|
|
c77806b1b1 | ||
|
|
68a5d31381 |
No files matched your search
+22
-4
@@ -2032,22 +2032,36 @@ _delegate_script() {
|
||||
printf '%s\n' "$(find "$skill_dir" -name "$name" 2>/dev/null | head -n 1 || true)"
|
||||
}
|
||||
|
||||
# _delegate_prompt_file <prompt> — write the prompt to a new file under the
|
||||
# control root's .mam/ and echo its path. Issue #36: a prompt handed to an exec
|
||||
# as one argv string is capped at 131,072 bytes; the caller removes the file.
|
||||
_delegate_prompt_file() {
|
||||
local dir="${WORKSPACE_ROOT:-$PWD}/.mam" f
|
||||
mkdir -p "$dir" || return 1
|
||||
f="$(mktemp "$dir/prompt-submit-XXXXXX")" || return 1
|
||||
printf '%s' "$1" > "$f" || { rm -f "$f"; return 1; }
|
||||
printf '%s\n' "$f"
|
||||
}
|
||||
|
||||
# delegate_submit_job <prompt> <agent> <agent_session>
|
||||
#
|
||||
# Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout.
|
||||
_delegate_submit_job_legacy() {
|
||||
local prompt="$1" agent="$2" session="$3"
|
||||
local py_bin registry_py
|
||||
local py_bin registry_py prompt_file rc=0
|
||||
py_bin="$(_delegate_py_bin)"
|
||||
registry_py="$(_delegate_script registry.py)"
|
||||
if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then
|
||||
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
|
||||
return 1
|
||||
fi
|
||||
prompt_file="$(_delegate_prompt_file "$prompt")" || { echo "ERROR: cannot write the prompt file" >&2; return 1; }
|
||||
"$py_bin" "$registry_py" register \
|
||||
--prompt "$prompt" \
|
||||
--prompt-file "$prompt_file" \
|
||||
--agent "$agent" \
|
||||
--agent-session "$session"
|
||||
--agent-session "$session" || rc=$?
|
||||
rm -f "$prompt_file"
|
||||
return $rc
|
||||
}
|
||||
|
||||
# delegate_publish_event <job_id> <event> [detail]
|
||||
@@ -2152,7 +2166,11 @@ delegate_submit_job() {
|
||||
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
|
||||
return 1
|
||||
fi
|
||||
_delegation_py submit --prompt "$prompt" --agent "$agent" --agent-session "$session"
|
||||
local prompt_file rc=0
|
||||
prompt_file="$(_delegate_prompt_file "$prompt")" || { echo "ERROR: cannot write the prompt file" >&2; return 1; }
|
||||
_delegation_py submit --prompt-file "$prompt_file" --agent "$agent" --agent-session "$session" || rc=$?
|
||||
rm -f "$prompt_file"
|
||||
return $rc
|
||||
}
|
||||
|
||||
# delegate_publish_event <job_id> <event> [detail]
|
||||
|
||||
@@ -26,7 +26,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_submit = sub.add_parser("submit", help="register a job; prints the job id")
|
||||
p_submit.add_argument("--prompt", required=True)
|
||||
# Issue #36: lib.sh passes the prompt by file (one argv string is capped
|
||||
# at 131,072 bytes); --prompt keeps working.
|
||||
submit_prompt = p_submit.add_mutually_exclusive_group(required=True)
|
||||
submit_prompt.add_argument("--prompt")
|
||||
submit_prompt.add_argument("--prompt-file")
|
||||
p_submit.add_argument("--agent", default="claude-code")
|
||||
p_submit.add_argument("--agent-session", default="herdr:claude")
|
||||
p_submit.add_argument("--registry-dir", default=None)
|
||||
@@ -57,7 +61,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
args = _build_parser().parse_args(argv)
|
||||
|
||||
if args.command == "submit":
|
||||
forward = ["register", "--prompt", args.prompt,
|
||||
prompt_arg = (["--prompt-file", args.prompt_file] if args.prompt_file is not None
|
||||
else ["--prompt", args.prompt])
|
||||
forward = ["register", *prompt_arg,
|
||||
"--agent", args.agent, "--agent-session", args.agent_session]
|
||||
if args.registry_dir:
|
||||
forward = ["--registry-dir", args.registry_dir] + forward
|
||||
|
||||
@@ -323,7 +323,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_reg = sub.add_parser("register", help="create a pending job; prints the job id")
|
||||
p_reg.add_argument("--prompt", required=True)
|
||||
# Issue #36: a long prompt goes by file - one argv string is capped at
|
||||
# MAX_ARG_STRLEN (131,072 bytes on Linux).
|
||||
reg_prompt = p_reg.add_mutually_exclusive_group(required=True)
|
||||
reg_prompt.add_argument("--prompt")
|
||||
reg_prompt.add_argument("--prompt-file", help="read the prompt from this file (utf-8)")
|
||||
p_reg.add_argument("--agent", default="claude-code")
|
||||
p_reg.add_argument("--agent-session", default="herdr:claude")
|
||||
p_reg.add_argument("--role", default="Worker", help="logical role for the delegated agent (e.g. Worker, Planner, Reviewer)")
|
||||
@@ -365,7 +369,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
p_update.add_argument("--job", required=True)
|
||||
p_update.add_argument("--status", default=None)
|
||||
p_update.add_argument("--agent-session", default=None)
|
||||
p_update.add_argument("--prompt", default=None)
|
||||
upd_prompt = p_update.add_mutually_exclusive_group()
|
||||
upd_prompt.add_argument("--prompt", default=None)
|
||||
upd_prompt.add_argument("--prompt-file", default=None, help="read the prompt from this file (utf-8)")
|
||||
p_update.add_argument("--iteration", type=int, default=None)
|
||||
p_update.add_argument("--attempt-id", type=int, default=None)
|
||||
p_update.add_argument("--role", default=None)
|
||||
@@ -473,15 +479,26 @@ def _operator_write(job_id: str, registry_dir: str, **fields) -> int:
|
||||
return 4
|
||||
|
||||
|
||||
def _prompt_from_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> Optional[str]:
|
||||
"""The prompt text: from --prompt-file (read verbatim) or from --prompt."""
|
||||
if getattr(args, "prompt_file", None) is None:
|
||||
return args.prompt
|
||||
try:
|
||||
return Path(args.prompt_file).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
parser.error(f"cannot read --prompt-file {args.prompt_file}: {exc}")
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
mqtt_common.ensure_env_loaded()
|
||||
mqtt_common.setup_logging(logging.INFO)
|
||||
args = _build_parser().parse_args(argv)
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
rd = args.registry_dir
|
||||
|
||||
if args.command == "register":
|
||||
job_id = register_job(
|
||||
prompt=args.prompt,
|
||||
prompt=_prompt_from_args(parser, args),
|
||||
agent=args.agent,
|
||||
agent_session=args.agent_session,
|
||||
role=args.role,
|
||||
@@ -540,8 +557,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
fields["status"] = args.status
|
||||
if args.agent_session is not None:
|
||||
fields["agent_session"] = args.agent_session
|
||||
if args.prompt is not None:
|
||||
fields["prompt"] = args.prompt
|
||||
prompt = _prompt_from_args(parser, args)
|
||||
if prompt is not None:
|
||||
fields["prompt"] = prompt
|
||||
if args.iteration is not None:
|
||||
fields["iteration"] = args.iteration
|
||||
if args.attempt_id is not None:
|
||||
|
||||
@@ -464,7 +464,17 @@ class LoopRunner:
|
||||
if not wrapper_script.exists():
|
||||
wrapper_script = Path(__file__).resolve().parent.parent / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
|
||||
def _run_cmd(p_text: str):
|
||||
def _run_cmd(p_text: str) -> subprocess.CompletedProcess:
|
||||
# Issue #36: the prompt goes by file. One argv string is capped at
|
||||
# MAX_ARG_STRLEN (131,072 bytes on Linux), and a review prompt
|
||||
# carries the whole diff. The wrapper blocks until the job ends,
|
||||
# so nothing reads the file after it returns.
|
||||
self.mam_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=self.mam_dir,
|
||||
prefix=f"prompt-{self.loop_id}-", suffix=".md",
|
||||
delete=False) as fh:
|
||||
fh.write(p_text)
|
||||
prompt_file = fh.name
|
||||
cmd = [
|
||||
"bash", str(wrapper_script), "submit",
|
||||
"--agent-session", self.session_label(agent_session),
|
||||
@@ -472,12 +482,24 @@ class LoopRunner:
|
||||
"--type", "direct",
|
||||
"--role", role,
|
||||
"--registry-dir", str(self.jobs_dir),
|
||||
"--prompt", p_text,
|
||||
"--prompt-file", prompt_file,
|
||||
] + self.contract_args()
|
||||
env = dict(os.environ)
|
||||
if "DELEGATE_JOB_PYTHON" not in env:
|
||||
env["DELEGATE_JOB_PYTHON"] = sys.executable
|
||||
return subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root), env=env)
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root), env=env)
|
||||
except OSError as e:
|
||||
# A wrapper that cannot start is a failed submit (126, the shell's
|
||||
# "cannot execute"), on the first try and on the retry alike.
|
||||
msg = (f"cannot start the wrapper: {e} "
|
||||
f"(prompt {len(p_text.encode('utf-8'))} bytes, wrapper {wrapper_script})")
|
||||
return subprocess.CompletedProcess(cmd, 126, "", msg)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(prompt_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
res = _run_cmd(prompt)
|
||||
match = re.search(r"registered job:\s*([A-Za-z0-9_-]+)", res.stdout)
|
||||
@@ -500,6 +522,7 @@ class LoopRunner:
|
||||
self.failed_submit_jobs.add(job_id_retry)
|
||||
log_error(f"delegate_job retry failed (exit {res_retry.returncode}):\n{res_retry.stderr or res_retry.stdout}")
|
||||
return job_id_retry
|
||||
log_error(f"delegate_job retry failed (exit {res_retry.returncode}):\n{res_retry.stderr or res_retry.stdout}")
|
||||
if job_id:
|
||||
self.created_jobs.append(job_id)
|
||||
self.failed_submit_jobs.add(job_id)
|
||||
@@ -1327,7 +1350,11 @@ class LoopRunner:
|
||||
adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}"
|
||||
adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt,
|
||||
remote=rt in self._remote_recipients, purpose="adjudication")
|
||||
if not adj_jid or not self.wait_for_job(adj_jid):
|
||||
if not adj_jid:
|
||||
# a job that was never registered is a failure, not a verdict (Issue #36)
|
||||
log_error(f"Failed to register re-adjudication job for '{rt}'.")
|
||||
return 1
|
||||
if not self.wait_for_job(adj_jid):
|
||||
log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.")
|
||||
overruled_revs.append(rt)
|
||||
continue
|
||||
@@ -1346,7 +1373,10 @@ class LoopRunner:
|
||||
p_type = self.resolve_agent_type(planner_session, state)
|
||||
arb_prompt = f"작업자(Creator)와 리뷰어의 의견이 충돌하여 교착 상태입니다. 계획서 관점에서 어느 쪽이 타당한지 재정(arbitration)하십시오. 리포트 마지막에 단독 행으로 '[ARBITRATION: CREATOR]' 또는 '[ARBITRATION: REVIEWER]' 를 명시하십시오. 리뷰어 지적:\n{feedback_aggregate}\n작업자 반론:\n{rebuttal_text}\n기존 계획서:\n{self.current_plan}"
|
||||
arb_jid = self._dispatch(planner_session, p_type, "Planner", arb_prompt)
|
||||
if arb_jid and self.wait_for_job(arb_jid):
|
||||
if not arb_jid:
|
||||
log_error("Failed to register Planner arbitration job.")
|
||||
return 1
|
||||
if self.wait_for_job(arb_jid):
|
||||
arb_file = self.find_report(arb_jid)
|
||||
if arb_file and parser.has_tag_line(arb_file, "ARBITRATION: CREATOR"):
|
||||
arbitration = "CREATOR"
|
||||
@@ -1360,8 +1390,9 @@ class LoopRunner:
|
||||
rebut_budget = 0
|
||||
comply_prompt = f"귀하의 반론은 재심 결과 기각되었습니다. 더 이상 반론하지 말고 다음 리뷰 피드백을 그대로 반영하여 코드를 수정하십시오. 피드백:\n{feedback_aggregate}"
|
||||
comp_jid = self._dispatch(self.creator, creator_type, "Worker", comply_prompt)
|
||||
if comp_jid:
|
||||
self.wait_for_job(comp_jid)
|
||||
if not comp_jid or not self.wait_for_job(comp_jid):
|
||||
log_error("Failed to register Creator compliance job.")
|
||||
return 1
|
||||
|
||||
# Promotion
|
||||
if self.plan_mode and self.current_plan and self.plan_job_id:
|
||||
|
||||
@@ -622,6 +622,19 @@ if [ -n "$DELEGATE_JOB_ID" ]; then
|
||||
# dispatch (trial-2 finding: without them the token-checking ack below could
|
||||
# never be satisfied by a real agent, and every onboarding sat out 180 s).
|
||||
pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")"
|
||||
# Issue #36: the task goes into a brief in the job's directory and the pane
|
||||
# gets only its path, as the delegate-job wrapper does. The injected text is
|
||||
# one argv string to herdr (capped at 131,072 bytes), so the whole task
|
||||
# plus this preamble could not reach the agent.
|
||||
task_reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
||||
[[ "$task_reg_dir" == /* ]] || task_reg_dir="$PWD/$task_reg_dir"
|
||||
task_brief="$task_reg_dir/$DELEGATE_JOB_ID/brief.md"
|
||||
if ! { mkdir -p "${task_brief%/*}" && printf '%s' "$SUBMIT_JOB_PROMPT" > "$task_brief"; }; then
|
||||
echo "ERROR: cannot write the task brief $task_brief" >&2
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" error "task brief could not be written" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
JOB_TERMINALIZED=1
|
||||
exit 1
|
||||
fi
|
||||
instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation).
|
||||
|
||||
On start run: $pub --event started.
|
||||
@@ -629,7 +642,7 @@ On progress (optional): $pub --event progress --detail '<short status>'.
|
||||
On success run: $pub --event completed --detail '<one-line summary>'.
|
||||
On failure run: $pub --event error --detail '<one-line reason>'.
|
||||
|
||||
Task: $SUBMIT_JOB_PROMPT"
|
||||
Task: read the complete task in $task_brief and follow it."
|
||||
|
||||
# Inject instructions into the herdr pane
|
||||
rc=0
|
||||
|
||||
@@ -44,6 +44,8 @@ multi-agent-mux-delegate-job submit \
|
||||
# Liveness contract (Issue #18): the agent heartbeats every N s (automatic),
|
||||
# the job is unresponsive after K x N s (--idle-timeout overrides), `started`
|
||||
# must arrive within T s of the injection or the inject fails closed.
|
||||
# --prompt-file <path> replaces --prompt for long prompts (Issue #36): one
|
||||
# argv string is capped at 131,072 bytes on Linux. The loop always uses it.
|
||||
|
||||
# 1b) Same, for an agent on another host (no herdr pane, no shared disk):
|
||||
# the brief travels over the broker as a signed retained dispatch and a
|
||||
|
||||
@@ -109,13 +109,15 @@ usage() {
|
||||
cat <<'EOF'
|
||||
multi-agent-mux-delegate-job <command> [options]
|
||||
|
||||
submit --agent <name> --prompt <text> [--workdir <dir>] [--agent-session <label>]
|
||||
submit --agent <name> (--prompt <text> | --prompt-file <path>) [--workdir <dir>] [--agent-session <label>]
|
||||
[--timeout <sec>] [--idle-timeout <sec>] [--validate <script>]
|
||||
[--heartbeat <sec>] [--heartbeat-misses <count>] [--ack-timeout <sec>]
|
||||
[--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]
|
||||
# --prompt-file passes the prompt by file: one argv string is capped
|
||||
# at 131,072 bytes on Linux (Issue #36). The file is read, not kept.
|
||||
# --agent-session herdr:<name> injects into a local herdr pane;
|
||||
# --agent-session remote:<name> dispatches over the broker (Issue #18).
|
||||
# Liveness contract: the agent heartbeats every --heartbeat N s
|
||||
@@ -131,7 +133,7 @@ EOF
|
||||
}
|
||||
|
||||
# ---- arg parsing helpers --------------------------------------------------
|
||||
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="herdr:claude"
|
||||
AGENT="claude-code"; PROMPT=""; PROMPT_FILE=""; WORKDIR="$(pwd)"; AGENT_SESSION="herdr:claude"
|
||||
TIMEOUT=3600; IDLE_TIMEOUT=""; VALIDATE=""; DRY_RUN=0
|
||||
# Issue #18 liveness contract (N / K / T). An unset --idle-timeout derives K x N.
|
||||
HEARTBEAT_SEC="${MAM_HEARTBEAT_SEC:-30}"; HEARTBEAT_MISSES="${MAM_HEARTBEAT_MISSES:-4}"; ACK_TIMEOUT="${MAM_ACK_TIMEOUT_S:-180}"
|
||||
@@ -148,6 +150,7 @@ parse_opts() {
|
||||
case "$1" in
|
||||
--agent) AGENT="$2"; shift 2;;
|
||||
--prompt) PROMPT="$2"; shift 2;;
|
||||
--prompt-file) PROMPT_FILE="$2"; shift 2;;
|
||||
--workdir) WORKDIR="$2"; shift 2;;
|
||||
--agent-session) AGENT_SESSION="$2"; shift 2;;
|
||||
--timeout) TIMEOUT="$2"; shift 2;;
|
||||
@@ -183,7 +186,17 @@ _to_lower() {
|
||||
|
||||
cmd_submit() {
|
||||
parse_opts "$@"
|
||||
[[ -n "$PROMPT" ]] || { echo "submit requires --prompt" >&2; exit 1; }
|
||||
if [[ -n "$PROMPT_FILE" ]]; then
|
||||
[[ -z "$PROMPT" ]] || { echo "submit takes --prompt or --prompt-file, not both" >&2; exit 1; }
|
||||
[[ "$PROMPT_FILE" == /* ]] || PROMPT_FILE="$(pwd)/$PROMPT_FILE"
|
||||
[[ -f "$PROMPT_FILE" && -r "$PROMPT_FILE" ]] || { echo "submit: cannot read --prompt-file $PROMPT_FILE" >&2; exit 1; }
|
||||
PROMPT="$(<"$PROMPT_FILE")"
|
||||
fi
|
||||
[[ -n "$PROMPT" || -n "$PROMPT_FILE" ]] || { echo "submit requires --prompt or --prompt-file" >&2; exit 1; }
|
||||
# Issue #36: the prompt reaches the registry by file when it came by file,
|
||||
# never as one argv string (capped at 131,072 bytes).
|
||||
local reg_prompt=(--prompt "$PROMPT")
|
||||
[[ -n "$PROMPT_FILE" ]] && reg_prompt=(--prompt-file "$PROMPT_FILE")
|
||||
PY="$(pick_python)"
|
||||
[[ "$PY" == */* && "$PY" != /* ]] && PY="$(pwd)/$PY"
|
||||
cd "$WORKDIR"
|
||||
@@ -192,7 +205,7 @@ cmd_submit() {
|
||||
|
||||
# 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" \
|
||||
"${reg_prompt[@]}" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \
|
||||
--timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
|
||||
--heartbeat-sec "$HEARTBEAT_SEC" --heartbeat-misses "$HEARTBEAT_MISSES" --ack-timeout "$ACK_TIMEOUT" \
|
||||
--transport "$(_is_remote "$AGENT_SESSION" && echo remote || echo herdr)" \
|
||||
@@ -392,6 +405,8 @@ EOF
|
||||
## 🔎 Task Description
|
||||
$current_prompt
|
||||
EOF
|
||||
# Issue #36: the registry gets the prompt by file, not as one argv string
|
||||
printf '%s' "$current_prompt" > "$job_dir/prompt.md"
|
||||
echo "provisioned iteration brief: $job_dir/brief.md"
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
@@ -432,7 +447,7 @@ EOF
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" update \
|
||||
--job "$JOB_ID" \
|
||||
--agent-session "$current_session" \
|
||||
--prompt "$current_prompt" \
|
||||
--prompt-file "$job_dir/prompt.md" \
|
||||
--iteration "$iteration" \
|
||||
--attempt-id "$attempt" \
|
||||
--role "$display_role" \
|
||||
|
||||
@@ -264,4 +264,5 @@ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
||||
- **Fail-closed on missing verdict**: 최종 Verdict 토큰이 누락되거나 리포트 픽업에 실패하면, 파서는 **경고 후 통과시키는 것이 아니라** 안전을 위해 즉시 `NOT PASS`로 판정(fail-closed)하고 교정 사이클을 수행합니다. 리뷰어에게는 반드시 리포트 끝에 단독 행으로 토큰을 찍도록 지시해야 합니다.
|
||||
- **Session Availability**: `run_loop.sh` 기동 전에 참조되는 Planner, Target Agent, Reviewer 세션들이 모두 herdr 세션으로 기동되어 (`status.sh` 기준 `alive` 및 `running`) 있어야 합니다.
|
||||
- **동시 루프 기동 금지 (NFS Lock Shadowing)**: 동일한 작업 트리 내에서 다수의 `run_loop.sh` 제어기를 동시에 기동하면 SQLite DB 갱신 경합 및 YAML 데이터 오염이 발생합니다. 하나의 루프가 끝날 때까지 다른 루프를 병렬로 기동하지 마십시오. (실제로는 loop lock 이 두 번째 루프를 exit 1 로 거부한다 — run_loop.sh 의 `mam_acquire_loop_lock` 분기)
|
||||
- **파이프라인의 종료 코드 (Issue #36)**: 위임이 실패하면 루프는 원인을 stderr 에 남기고 0 이 아닌 코드로 끝납니다. 루프를 `… | tee log` 처럼 파이프라인으로 돌리면 `$?` 는 `tee` 의 것이므로 `set -o pipefail` 또는 `${PIPESTATUS[0]}` 로 루프의 코드를 보십시오. stderr 도 함께 남기십시오(`2>&1`).
|
||||
- **원자적 아카이빙 (Promotion)**: 루프 성공 종료 시 최종 계획서와 검증 리포트들은 `.agents/reports/<session_name>/` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다.
|
||||
@@ -6,6 +6,9 @@ MAM_DIFF_UNAVAILABLE_MARKER="!!! CHANGE SET UNAVAILABLE !!!"
|
||||
MAM_DIFF_TRUNCATED_MARKER="!!! DIFF TRUNCATED !!!"
|
||||
MAM_DIFF_NESTED_MARKER="!!! NOT EXPANDED IN THIS DIFF !!!"
|
||||
|
||||
# These caps bound the evidence a reviewer is shown, nothing else. The prompt
|
||||
# reaches the agent by file (Issue #36), so delivery has no size cap of its own:
|
||||
# only a diff over these limits is truncated (and marked).
|
||||
MAM_DIFF_MAX_BYTES="${MAM_DIFF_MAX_BYTES:-200000}"
|
||||
MAM_DIFF_MAX_LINES="${MAM_DIFF_MAX_LINES:-4000}"
|
||||
|
||||
|
||||
@@ -187,14 +187,37 @@ _mam_release_guard() {
|
||||
# Runs the delegate-job wrapper from the frozen skills tree (REPO_ROOT).
|
||||
# Stage 2 creates a single snapshot at loop initialization outside the skill tree (B-13),
|
||||
# keeping the running loop immune across turns without per-call copies or tree pollution (B-6).
|
||||
#
|
||||
# Issue #36: a `--prompt <text>` argument is handed to the wrapper as
|
||||
# `--prompt-file <path>` instead - one argv string is capped at 131,072 bytes
|
||||
# and a review prompt carries the whole diff. The file lives under the control
|
||||
# root's .mam/ and is removed once the wrapper returns (it blocks until the
|
||||
# job ends, so nothing reads the file afterwards).
|
||||
delegate_job_safe() {
|
||||
local wrapper_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
|
||||
local rc=0
|
||||
bash "$wrapper_script" "$@" || rc=$?
|
||||
local rc=0 prompt_file="" args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--prompt" ] && [ $# -ge 2 ]; then
|
||||
mkdir -p "$MAM_REAL_ROOT/.mam" \
|
||||
&& prompt_file="$(mktemp "$MAM_REAL_ROOT/.mam/prompt-shloop-XXXXXX")" \
|
||||
&& printf '%s' "$2" > "$prompt_file" \
|
||||
|| { [ -n "$prompt_file" ] && rm -f "$prompt_file"
|
||||
log_error "delegate_job_safe: cannot write the prompt file under $MAM_REAL_ROOT/.mam" >&2; return 1; }
|
||||
args+=(--prompt-file "$prompt_file")
|
||||
shift 2
|
||||
else
|
||||
args+=("$1")
|
||||
shift
|
||||
fi
|
||||
done
|
||||
bash "$wrapper_script" ${args[@]+"${args[@]}"} || rc=$?
|
||||
[ -n "$prompt_file" ] && rm -f "$prompt_file"
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
log_error "delegate_job_safe failed (exit $rc): $wrapper_script"
|
||||
log_error " if this loop edits framework skills in place, check that file's syntax:"
|
||||
log_error " bash -n \"$wrapper_script\""
|
||||
# stderr: callers capture stdout with $(...), and under set -e a failed
|
||||
# submit ends the loop right there - this line is the only cause shown.
|
||||
log_error "delegate_job_safe failed (exit $rc): $wrapper_script" >&2
|
||||
log_error " if this loop edits framework skills in place, check that file's syntax:" >&2
|
||||
log_error " bash -n \"$wrapper_script\"" >&2
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
"""Issue #36: a delegated prompt never travels as one argv string.
|
||||
|
||||
Linux caps a single argv (or env) string at MAX_ARG_STRLEN = 131,072 bytes,
|
||||
independent of ARG_MAX. A review prompt carries the whole diff, and the diff
|
||||
collector lets up to 200,000 bytes through, so a 131,072..200,000-byte diff
|
||||
killed the loop with `OSError: [Errno 7] Argument list too long: 'bash'`.
|
||||
|
||||
The prompt now goes by file: runner -> wrapper (--prompt-file) -> registry
|
||||
(--prompt-file). These tests push prompts over the cap through the real
|
||||
bash wrapper, the real registry and a real `python -m lib_py.loop` process;
|
||||
subprocess is replaced only where a test is about the error handling itself
|
||||
(S4, S4b), never to prove the transport.
|
||||
|
||||
Every test writes only under tmp_path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS = REPO_ROOT / ".agents" / "skills"
|
||||
WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
DIFF_COLLECT = SKILLS / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
|
||||
|
||||
if str(SKILLS) not in sys.path:
|
||||
sys.path.insert(0, str(SKILLS))
|
||||
|
||||
MAX_ARG_STRLEN = 131072
|
||||
BIG = 140_000
|
||||
UNREACHABLE = {"MQTT_BROKER": "127.0.0.1", "MQTT_PORT": "9", "MQTT_TLS": "0"}
|
||||
GIT_ID = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"}
|
||||
TRUNCATED = "!!! DIFF TRUNCATED !!!"
|
||||
|
||||
linux_only = pytest.mark.skipif(sys.platform != "linux", reason="MAX_ARG_STRLEN is a Linux constant")
|
||||
|
||||
|
||||
def _env(home: Path, extra=None, drop=()) -> dict:
|
||||
env = dict(os.environ)
|
||||
env["DELEGATE_JOB_PYTHON"] = sys.executable
|
||||
env["AGENT_PYTHON_BIN"] = sys.executable
|
||||
env["HOME"] = str(home)
|
||||
env["MAM_ENV_FILE"] = str(home / ".mam.env.absent")
|
||||
env["PYTHONPATH"] = str(SKILLS)
|
||||
for k in ("MQTT_BROKER", "MQTT_HOST", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD",
|
||||
"MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR", "WORKSPACE_ROOT", "MAM_REAL_ROOT"):
|
||||
env.pop(k, None)
|
||||
env.update(UNREACHABLE)
|
||||
env.update(GIT_ID)
|
||||
# never this checkout's .mam (audit logs, herdr shim)
|
||||
env["DELEGATE_JOB_LOGS_DIR"] = str(home / ".mam-logs")
|
||||
env["WORKSPACE_ROOT"] = str(home)
|
||||
if extra:
|
||||
env.update({k: str(v) for k, v in extra.items()})
|
||||
for k in drop:
|
||||
env.pop(k, None)
|
||||
return env
|
||||
|
||||
|
||||
def _big_text(n: int = BIG) -> str:
|
||||
"""ASCII with newlines, exactly n bytes."""
|
||||
line = "x" * 63 + "\n"
|
||||
return (line * (n // len(line) + 1))[:n]
|
||||
|
||||
|
||||
def _dry_submit(cwd: Path, reg: Path, *prompt_args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human",
|
||||
"--agent-session", "herdr:x", "--dry-run",
|
||||
"--registry-dir", str(reg), *prompt_args],
|
||||
capture_output=True, text=True, env=_env(cwd), cwd=str(cwd), timeout=120)
|
||||
|
||||
|
||||
def _registered_prompt(res: subprocess.CompletedProcess, reg: Path) -> str:
|
||||
assert res.returncode == 0, res.stderr
|
||||
m = re.search(r"registered job:\s*(\S+)", res.stdout)
|
||||
assert m, res.stdout
|
||||
return json.loads((reg / f"{m.group(1)}.json").read_text(encoding="utf-8"))["prompt"]
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True,
|
||||
check=True, env={**os.environ, **GIT_ID}).stdout.strip()
|
||||
|
||||
|
||||
def _repo(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
_git(path, "init", "-q")
|
||||
(path / "a.txt").write_text("a\n")
|
||||
_git(path, "add", "a.txt")
|
||||
_git(path, "commit", "-q", "-m", "init")
|
||||
return path
|
||||
|
||||
|
||||
def _fake_wrapper(root: Path, body: str) -> Path:
|
||||
"""A wrapper at the control root, where the runner looks first."""
|
||||
w = root / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
w.parent.mkdir(parents=True, exist_ok=True)
|
||||
w.write_text("#!/usr/bin/env bash\n" + body, encoding="utf-8")
|
||||
w.chmod(0o755)
|
||||
return w
|
||||
|
||||
|
||||
def _prompt_leftovers(root: Path) -> list:
|
||||
return sorted(p.name for p in (root / ".mam").glob("prompt-*"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S1 - the real wrapper and registry take a prompt over the cap by file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_s1_wrapper_registers_a_prompt_over_the_cap_by_file(tmp_path):
|
||||
text = _big_text()
|
||||
pf = tmp_path / "prompt.md"
|
||||
pf.write_text(text, encoding="utf-8")
|
||||
reg = tmp_path / "reg"
|
||||
res = _dry_submit(tmp_path, reg, "--prompt-file", str(pf))
|
||||
assert _registered_prompt(res, reg) == text # byte for byte, trailing newline too
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_s1b_the_same_prompt_as_an_argument_cannot_even_start(tmp_path):
|
||||
"""Negative control: the failure S1 avoids is real, not a fixture artefact."""
|
||||
with pytest.raises(OSError) as exc:
|
||||
_dry_submit(tmp_path, tmp_path / "reg", "--prompt", _big_text())
|
||||
assert exc.value.errno == errno.E2BIG
|
||||
|
||||
|
||||
def test_s1c_prompt_still_works_and_the_two_options_exclude_each_other(tmp_path):
|
||||
reg = tmp_path / "reg"
|
||||
assert _registered_prompt(_dry_submit(tmp_path, reg, "--prompt", "short"), reg) == "short"
|
||||
|
||||
pf = tmp_path / "p.md"
|
||||
pf.write_text("from file\n", encoding="utf-8")
|
||||
both = _dry_submit(tmp_path, tmp_path / "reg2", "--prompt", "x", "--prompt-file", str(pf))
|
||||
assert both.returncode == 1 and "not both" in both.stderr
|
||||
missing = _dry_submit(tmp_path, tmp_path / "reg3", "--prompt-file", str(tmp_path / "absent.md"))
|
||||
assert missing.returncode == 1 and "cannot read --prompt-file" in missing.stderr
|
||||
neither = _dry_submit(tmp_path, tmp_path / "reg4")
|
||||
assert neither.returncode == 1
|
||||
|
||||
# relative to the caller's cwd, even though the wrapper cds to --workdir
|
||||
work = tmp_path / "elsewhere"
|
||||
work.mkdir()
|
||||
reg5 = tmp_path / "reg5"
|
||||
rel = _dry_submit(tmp_path, reg5, "--prompt-file", "p.md", "--workdir", str(work))
|
||||
assert _registered_prompt(rel, reg5) == "from file\n"
|
||||
|
||||
|
||||
def test_s1d_registry_update_takes_a_prompt_file(tmp_path):
|
||||
"""Hop 3 (the wrapper's loop type) updates the record by file too."""
|
||||
reg = tmp_path / "reg"
|
||||
assert _registered_prompt(_dry_submit(tmp_path, reg, "--prompt", "v1"), reg) == "v1"
|
||||
jid = next(reg.glob("*.json")).stem
|
||||
pf = tmp_path / "v2.md"
|
||||
text = _big_text()
|
||||
pf.write_text(text, encoding="utf-8")
|
||||
res = subprocess.run([sys.executable, str(SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "registry.py"),
|
||||
"--registry-dir", str(reg), "update", "--job", jid, "--prompt-file", str(pf)],
|
||||
capture_output=True, text=True, env=_env(tmp_path), cwd=str(tmp_path), timeout=60)
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert json.loads((reg / f"{jid}.json").read_text(encoding="utf-8"))["prompt"] == text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S2 - the runner execs the wrapper with a file, not the text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_s2_runner_hands_the_wrapper_a_file(tmp_path, monkeypatch):
|
||||
rec = tmp_path / "rec"
|
||||
_fake_wrapper(tmp_path, f"""
|
||||
printf '%s\\n' "$@" > "{rec}.args"
|
||||
pf=""
|
||||
while [ $# -gt 0 ]; do case "$1" in --prompt-file) pf="$2"; shift 2;; *) shift;; esac; done
|
||||
cp "$pf" "{rec}.prompt"
|
||||
echo "registered job: fake36"
|
||||
""")
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
for k in ("MAM_REAL_ROOT", "MAM_JOBS_DIR"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
from lib_py.loop import runner as lr
|
||||
r = lr.LoopRunner(["--creator", "c", "--task", "t"])
|
||||
text = _big_text()
|
||||
|
||||
assert r.delegate_job("c", "claude", "Worker", text) == "fake36"
|
||||
args = Path(f"{rec}.args").read_text().splitlines()
|
||||
assert "--prompt-file" in args and "--prompt" not in args
|
||||
assert Path(f"{rec}.prompt").read_text(encoding="utf-8") == text
|
||||
assert _prompt_leftovers(tmp_path) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S3 - a failed delegation ends a real loop process with rc 1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _loop_env(ctl: Path, extra=None) -> dict:
|
||||
return _env(ctl, extra={
|
||||
"WORKSPACE_ROOT": ctl,
|
||||
"MAM_JOBS_DIR": ctl / ".mam" / "jobs",
|
||||
"DELEGATE_JOB_LOGS_DIR": ctl / ".mam" / "logs",
|
||||
"MAM_SESSION_BUSY_RETRY_S": "0",
|
||||
"MAM_STATE_JSON": json.dumps({"herdr_sessions": [
|
||||
{"name": "c", "agent": "claude", "role": "creator", "status": "running"},
|
||||
{"name": "r", "agent": "claude", "role": "reviewer", "status": "running"}]}),
|
||||
**(extra or {}),
|
||||
}, drop=("MAM_LOOP_WORKTREE", "MAM_LOOP_BASE", "MAM_VERSION"))
|
||||
|
||||
|
||||
def _run_loop(ctl: Path, env: dict) -> subprocess.CompletedProcess:
|
||||
plan = ctl / "plan.md"
|
||||
plan.write_text("# plan\n\ndo it\n", encoding="utf-8")
|
||||
return subprocess.run([sys.executable, "-m", "lib_py.loop", "--creator", "c", "--task", "t36",
|
||||
"--reviewer", "r", "--max-loop", "1", "--plan-file", str(plan)],
|
||||
capture_output=True, text=True, env=env, cwd=str(ctl), timeout=120)
|
||||
|
||||
|
||||
def test_s3_a_wrapper_that_fails_ends_the_loop_with_rc_1(tmp_path):
|
||||
ctl = _repo(tmp_path / "ctl")
|
||||
_fake_wrapper(ctl, "echo 'submit refused' >&2\nexit 2\n")
|
||||
res = _run_loop(ctl, _loop_env(ctl))
|
||||
assert res.returncode == 1, res.stdout + res.stderr
|
||||
assert "Failed to register Creator execution job" in res.stderr
|
||||
assert "Traceback" not in res.stderr
|
||||
|
||||
|
||||
def test_s3b_a_wrapper_that_cannot_start_ends_the_loop_with_rc_1(tmp_path):
|
||||
"""exec itself fails (no bash on PATH): reported, no traceback, rc 1."""
|
||||
ctl = _repo(tmp_path / "ctl")
|
||||
_fake_wrapper(ctl, "echo 'registered job: never'\n")
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
git = subprocess.run(["bash", "-c", "command -v git"], capture_output=True, text=True).stdout.strip()
|
||||
(bin_dir / "git").symlink_to(git)
|
||||
res = _run_loop(ctl, _loop_env(ctl, {"PATH": bin_dir}))
|
||||
assert res.returncode == 1, res.stdout + res.stderr
|
||||
assert "submit failed (exit 126)" in res.stderr
|
||||
assert "cannot start the wrapper" in res.stderr
|
||||
assert "Failed to register Creator execution job" in res.stderr
|
||||
assert "Traceback" not in res.stderr
|
||||
assert _prompt_leftovers(ctl) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S4 - the handler: an exec error is a failed submit, on the retry too
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def runner(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("MAM_SESSION_BUSY_RETRY_S", "0")
|
||||
for k in ("MAM_REAL_ROOT", "MAM_JOBS_DIR"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
from lib_py.loop import runner as lr
|
||||
return lr, lr.LoopRunner(["--creator", "c", "--task", "t"])
|
||||
|
||||
|
||||
def test_s4_an_exec_error_is_a_failed_submit(runner, tmp_path, monkeypatch, capsys):
|
||||
lr, r = runner
|
||||
|
||||
def boom(*a, **k):
|
||||
raise OSError(errno.E2BIG, "Argument list too long")
|
||||
|
||||
monkeypatch.setattr(lr.subprocess, "run", boom)
|
||||
assert r.delegate_job("c", "claude", "Worker", "p" * 1000) is None
|
||||
err = capsys.readouterr().err
|
||||
assert "submit failed (exit 126)" in err
|
||||
assert "Argument list too long" in err and "prompt 1000 bytes" in err
|
||||
assert _prompt_leftovers(tmp_path) == []
|
||||
|
||||
|
||||
def test_s4b_an_exec_error_on_the_busy_retry_is_reported_not_raised(runner, tmp_path, monkeypatch, capsys):
|
||||
lr, r = runner
|
||||
calls = []
|
||||
|
||||
def run(cmd, **k):
|
||||
calls.append(cmd)
|
||||
if len(calls) == 1:
|
||||
return subprocess.CompletedProcess(cmd, 5, "subscriber pid: 1\n", "session busy\n")
|
||||
raise OSError(errno.EAGAIN, "Resource temporarily unavailable")
|
||||
|
||||
monkeypatch.setattr(lr.subprocess, "run", run)
|
||||
assert r.delegate_job("c", "claude", "Worker", "p") is None
|
||||
assert len(calls) == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "submit failed (exit 5)" in err
|
||||
assert "retry failed (exit 126)" in err
|
||||
assert "cannot start the wrapper" in err
|
||||
assert _prompt_leftovers(tmp_path) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S3c - a job that cannot be registered after the rebuttal ends the loop with
|
||||
# rc 1, even when the next review round would pass (real loop process; only
|
||||
# the agents' outcomes are simulated, by an executable behind the wrapper)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FAKE_AGENT = r"""
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
args = sys.argv[3:] # argv[1] = kind to refuse, argv[2] = "submit"
|
||||
opts = dict(zip(args[::2], args[1::2]))
|
||||
prompt = Path(opts["--prompt-file"]).read_text(encoding="utf-8")
|
||||
refuse = sys.argv[1]
|
||||
jobs = Path(opts.get("--registry-dir", ".mam/jobs")) # the shell loop passes none
|
||||
jobs.mkdir(parents=True, exist_ok=True)
|
||||
kind = ("comply" if "재심 결과 기각" in prompt else
|
||||
"adjudication" if "re-adjudication" in prompt else
|
||||
"arbitration" if "재정(arbitration)" in prompt else
|
||||
"correction" if "[이의제기 채널]" in prompt else
|
||||
opts["--role"])
|
||||
review = "Self-Review" if "Self-Review" in prompt else kind
|
||||
with open(".mam/calls.log", "a", encoding="utf-8") as log: # what reached the agent, in bytes
|
||||
log.write("%s\t%d\n" % (review, len(prompt.encode("utf-8"))))
|
||||
if kind == refuse:
|
||||
print(kind + " registration refused", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if kind == "adjudication":
|
||||
report = "no\n\n[ADJUDICATION: OVERRULED]\n"
|
||||
elif kind == "arbitration":
|
||||
report = "reviewer is right\n\n[ARBITRATION: REVIEWER]\n"
|
||||
elif kind == "correction":
|
||||
report = "I disagree\n\n[REBUT: r]\n"
|
||||
elif kind == "Reviewer" and "Self-Review" in prompt:
|
||||
report = "ok\n\n[VERDICT: PASS]\n"
|
||||
elif kind == "Reviewer": # the first cross review objects, every later one passes
|
||||
counter = Path(".mam/review-count")
|
||||
n = int(counter.read_text()) + 1 if counter.exists() else 1
|
||||
counter.write_text(str(n))
|
||||
report = "line 3 is wrong\n\n[VERDICT: NOT PASS]\n" if n == 1 else "ok\n\n[VERDICT: PASS]\n"
|
||||
elif kind == "Planner":
|
||||
report = "# plan\n\n" + "p" * int(os.environ.get("FAKE_PLAN_BYTES", "5")) + "\n"
|
||||
else:
|
||||
grow = int(os.environ.get("FAKE_GROW_BYTES", "0"))
|
||||
if grow and kind == "Worker": # the implementation: one big new file in the tree
|
||||
Path("big.txt").write_text(("g" * 63 + "\n") * (grow // 64), encoding="utf-8")
|
||||
report = "done\n"
|
||||
jid = "j%d" % len(list(jobs.glob("*.json")))
|
||||
(jobs / (jid + ".json")).write_text(json.dumps({"job_id": jid, "status": "completed"}))
|
||||
dest = jobs / jid / "agent-reports"
|
||||
dest.mkdir(parents=True)
|
||||
(dest / "report-final.md").write_text(report, encoding="utf-8")
|
||||
print("registered job: " + jid)
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("refuse, message", [
|
||||
("comply", "Failed to register Creator compliance job."),
|
||||
("adjudication", "Failed to register re-adjudication job for 'r'."),
|
||||
("arbitration", "Failed to register Planner arbitration job."),
|
||||
])
|
||||
def test_s3c_a_registration_failure_after_a_rebuttal_is_never_a_success(tmp_path, refuse, message):
|
||||
ctl = _repo(tmp_path / "ctl")
|
||||
agent = ctl / "fake-agent.py"
|
||||
agent.write_text(FAKE_AGENT, encoding="utf-8")
|
||||
_fake_wrapper(ctl, f'exec "{sys.executable}" "{agent}" {refuse} "$@"\n')
|
||||
extra = {}
|
||||
argv = ["--max-loop", "2"]
|
||||
if refuse == "arbitration": # arbitration needs --plan and a planner
|
||||
extra["MAM_STATE_JSON"] = json.dumps({"herdr_sessions": [
|
||||
{"name": "c", "agent": "claude", "role": "creator", "status": "running"},
|
||||
{"name": "r", "agent": "claude", "role": "reviewer", "status": "running"},
|
||||
{"name": "p", "agent": "claude", "role": "planner", "status": "running"}]})
|
||||
argv += ["--plan", "--plan-talk", "0"]
|
||||
else:
|
||||
plan = ctl / "plan.md"
|
||||
plan.write_text("# plan\n\ndo it\n", encoding="utf-8")
|
||||
argv += ["--plan-file", str(plan)]
|
||||
res = subprocess.run([sys.executable, "-m", "lib_py.loop", "--creator", "c", "--task", "t36",
|
||||
"--reviewer", "r", *argv],
|
||||
capture_output=True, text=True, env=_loop_env(ctl, extra), cwd=str(ctl), timeout=120)
|
||||
assert message in res.stderr, res.stdout + res.stderr
|
||||
assert res.returncode == 1, res.stdout + res.stderr
|
||||
assert "100% PASS" not in res.stdout
|
||||
assert "Traceback" not in res.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S5 - the former blind spot: a 131,072..200,000-byte diff goes through whole
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _collect(repo: Path, base: str, head: str, extra=None) -> bytes:
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("MAM_DIFF_MAX_BYTES", "MAM_DIFF_MAX_LINES")}
|
||||
env.update(extra or {})
|
||||
res = subprocess.run(["bash", "-c", 'source "$1" && mam_collect_changes_diff "$2" "$3" "$4"',
|
||||
"_", str(DIFF_COLLECT), str(repo), base, head],
|
||||
capture_output=True, env=env, timeout=60)
|
||||
assert res.returncode == 0, res.stderr
|
||||
return res.stdout
|
||||
|
||||
|
||||
def test_s5_a_diff_in_the_former_blind_spot_is_delivered_whole(tmp_path):
|
||||
repo = _repo(tmp_path / "src")
|
||||
base = _git(repo, "rev-parse", "HEAD")
|
||||
(repo / "big.txt").write_text(_big_text(150_000), encoding="utf-8")
|
||||
_git(repo, "add", "big.txt")
|
||||
_git(repo, "commit", "-q", "-m", "grow")
|
||||
head = _git(repo, "rev-parse", "HEAD")
|
||||
|
||||
diff = _collect(repo, base, head)
|
||||
assert TRUNCATED.encode() not in diff
|
||||
assert MAX_ARG_STRLEN < len(diff) <= 200_000
|
||||
|
||||
pf = tmp_path / "diff.md"
|
||||
pf.write_bytes(diff)
|
||||
reg = tmp_path / "reg"
|
||||
assert _registered_prompt(_dry_submit(tmp_path, reg, "--prompt-file", str(pf)), reg) == diff.decode("utf-8")
|
||||
|
||||
# the collector cap itself is unchanged: over it, the diff is still truncated and marked
|
||||
assert TRUNCATED.encode() in _collect(repo, base, head, {"MAM_DIFF_MAX_BYTES": "100000"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S6 - lib.sh's submit helper (create_session's onboarding and --submit-job)
|
||||
# and the delegation CLI behind it take a prompt over the cap by file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LIB_SH = SKILLS / "lib.sh"
|
||||
|
||||
|
||||
def _helper_env(root: Path, extra=None) -> dict:
|
||||
return _env(root, extra={"MAM_JOBS_DIR": root / "reg", **(extra or {})})
|
||||
|
||||
|
||||
def test_s6_delegation_cli_submit_takes_a_prompt_file(tmp_path):
|
||||
text = _big_text()
|
||||
pf = tmp_path / "p.md"
|
||||
pf.write_text(text, encoding="utf-8")
|
||||
res = subprocess.run([sys.executable, "-m", "lib_py.delegation.cli", "submit", "--prompt-file", str(pf),
|
||||
"--agent", "claude-code", "--agent-session", "herdr:x"],
|
||||
capture_output=True, text=True, env=_helper_env(tmp_path), cwd=str(tmp_path), timeout=60)
|
||||
assert res.returncode == 0, res.stderr
|
||||
jid = res.stdout.strip().splitlines()[-1]
|
||||
assert json.loads((tmp_path / "reg" / f"{jid}.json").read_text(encoding="utf-8"))["prompt"] == text
|
||||
|
||||
both = subprocess.run([sys.executable, "-m", "lib_py.delegation.cli", "submit", "--prompt", "x",
|
||||
"--prompt-file", str(pf)],
|
||||
capture_output=True, text=True, env=_helper_env(tmp_path), cwd=str(tmp_path), timeout=60)
|
||||
assert both.returncode == 2 and "not allowed with argument" in both.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delegation_py", ["1", "0"]) # the Python helper and its legacy twin
|
||||
def test_s6b_delegate_submit_job_passes_a_prompt_over_the_cap(tmp_path, delegation_py):
|
||||
text = _big_text()
|
||||
pf = tmp_path / "p.md"
|
||||
pf.write_text(text, encoding="utf-8")
|
||||
# The prompt reaches the shell function as a bash argument (no exec, no cap),
|
||||
# exactly as create_session.sh hands it over.
|
||||
res = subprocess.run(["bash", "-c", 'source "$1" && delegate_submit_job "$(cat "$2")" claude-code herdr:x',
|
||||
"_", str(LIB_SH), str(pf)],
|
||||
capture_output=True, text=True, cwd=str(tmp_path), timeout=60,
|
||||
env=_helper_env(tmp_path, {"MAM_DELEGATION_PY": delegation_py}))
|
||||
assert res.returncode == 0, res.stderr
|
||||
jid = res.stdout.strip().splitlines()[-1]
|
||||
assert json.loads((tmp_path / "reg" / f"{jid}.json").read_text(encoding="utf-8"))["prompt"] == text
|
||||
assert sorted(p.name for p in (tmp_path / ".mam").glob("prompt-*")) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S7 - the legacy shell loop (MAM_LOOP_PY=0): the real run_loop.sh hands every
|
||||
# prompt over by file, and a registration failure never ends it with rc 0.
|
||||
# run_loop.sh always runs the wrapper next to itself, so the skills tree is
|
||||
# copied under tmp_path (as the mam_sandbox fixture does) and only that copy's
|
||||
# wrapper is replaced by the agent simulator.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _shell_ctl(tmp_path: Path, refuse: str) -> Path:
|
||||
import shutil
|
||||
ctl = tmp_path / "ctl"
|
||||
shutil.copytree(SKILLS, ctl / ".agents" / "skills", ignore=shutil.ignore_patterns("__pycache__"))
|
||||
(ctl / ".gitignore").write_text(".mam/\n.agents/reports/\n")
|
||||
agent = ctl / "fake-agent.py"
|
||||
agent.write_text(FAKE_AGENT, encoding="utf-8")
|
||||
_fake_wrapper(ctl, f'exec "{sys.executable}" "{agent}" {refuse} "$@"\n')
|
||||
_repo(ctl) # the base holds the copied tree, so the diff is only what the "Creator" writes
|
||||
_git(ctl, "add", "-A")
|
||||
_git(ctl, "commit", "-q", "-m", "skills")
|
||||
(ctl / ".mam").mkdir()
|
||||
(ctl / ".mam" / "agent-sessions.yaml").write_text(
|
||||
"herdr_sessions:\n"
|
||||
"- {name: c, agent: claude, role: creator, status: running}\n"
|
||||
"- {name: r, agent: claude, role: reviewer, status: running}\n"
|
||||
"- {name: p, agent: claude, role: planner, status: running}\n", encoding="utf-8")
|
||||
return ctl
|
||||
|
||||
|
||||
def _run_shell_loop(ctl: Path, argv, extra=None) -> subprocess.CompletedProcess:
|
||||
env = _env(ctl, extra={
|
||||
"MAM_LOOP_PY": "0", "MAM_LOOP_NO_FREEZE": "1", # no /tmp snapshot of the skills
|
||||
"MAM_LOOP_MARKER": ctl / ".mam" / "loop-marker",
|
||||
"AGENT_SESSIONS_YAML": ctl / ".mam" / "agent-sessions.yaml",
|
||||
"WORKSPACE_ROOT": ctl, **(extra or {})},
|
||||
drop=("MAM_LOOP_WORKTREE", "MAM_LOOP_BASE", "MAM_VERSION", "MAM_STATE_JSON"))
|
||||
run_loop = ctl / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
||||
return subprocess.run(["bash", str(run_loop), "--creator", "c", "--task", "t36", "--reviewer", "r", *argv],
|
||||
capture_output=True, text=True, env=env, cwd=str(ctl), timeout=300)
|
||||
|
||||
|
||||
def _calls(ctl: Path) -> list:
|
||||
return [tuple(line.split("\t")) for line in (ctl / ".mam" / "calls.log").read_text().splitlines()]
|
||||
|
||||
|
||||
def test_s7_shell_loop_hands_prompts_over_the_cap_to_the_wrapper(tmp_path):
|
||||
ctl = _shell_ctl(tmp_path, "none")
|
||||
# a 140,000-byte plan makes the execution prompt, and a 150,000-byte new
|
||||
# file makes the collected diff (so the review prompt), exceed the cap
|
||||
res = _run_shell_loop(ctl, ["--plan", "--plan-talk", "0", "--max-loop", "2"],
|
||||
{"FAKE_PLAN_BYTES": BIG, "FAKE_GROW_BYTES": 150_000})
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert "Argument list too long" not in res.stderr
|
||||
sizes = {}
|
||||
for kind, n in _calls(ctl):
|
||||
sizes.setdefault(kind, []).append(int(n))
|
||||
assert max(sizes["Worker"]) > MAX_ARG_STRLEN # execution prompt with the plan
|
||||
assert max(sizes["Reviewer"]) > MAX_ARG_STRLEN # cross review with the diff
|
||||
assert sorted(p.name for p in (ctl / ".mam").glob("prompt-*")) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("refuse, argv", [
|
||||
("Worker", ["--max-loop", "1"]), # the implementation job
|
||||
("comply", ["--max-loop", "2"]), # the compliance job after an overruled rebuttal; round 2 would pass
|
||||
])
|
||||
def test_s7b_shell_loop_ends_nonzero_when_a_job_cannot_be_registered(tmp_path, refuse, argv):
|
||||
"""The shell loop runs under set -e: a failed submit inside $(...) ends it
|
||||
at that line with the wrapper's code, so the cause must reach stderr."""
|
||||
ctl = _shell_ctl(tmp_path, refuse)
|
||||
res = _run_shell_loop(ctl, argv)
|
||||
out = res.stdout + res.stderr
|
||||
assert res.returncode == 2, out
|
||||
assert f"{refuse} registration refused" in res.stderr
|
||||
assert "delegate_job_safe failed (exit 2)" in res.stderr
|
||||
assert "100% PASS" not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S8 - create_session's onboarding / --submit-job: the task reaches the agent
|
||||
# through a brief in the job directory, never inside the injected text
|
||||
# (herdr `agent prompt` takes that text as one argv string). Real
|
||||
# create_session.sh, registry and send_keys_safe; herdr and the agent CLIs are
|
||||
# the conftest executables. From the review of job cc849cbf (codex).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("size", [100, 130_500]) # the second is under the cap on its own
|
||||
def test_s8_create_session_task_reaches_the_pane_through_a_brief(mam_sandbox, mock_herdr, mock_agents,
|
||||
monkeypatch, size):
|
||||
import shutil
|
||||
monkeypatch.setenv("MAM_ONBOARD_ACK_S", "1")
|
||||
monkeypatch.setenv("SKS_DELIVERY_GRACE_S", "0")
|
||||
script = mam_sandbox / ".agents/skills/multi-agent-mux-create/scripts/create_session.sh"
|
||||
prompt = "q" * size
|
||||
cmd = ["bash", str(script), "--workspace", str(mam_sandbox), "--agent", "claude",
|
||||
"--role", "Creator", "--session", "review36-creator-claude", "--submit-job", prompt]
|
||||
trace = mam_sandbox / "exec.trace"
|
||||
strace = shutil.which("strace")
|
||||
if strace: # the kernel's own verdict on every exec, when available
|
||||
cmd = [strace, "-f", "-s", "100", "-e", "trace=execve", "-o", str(trace)] + cmd
|
||||
result = subprocess.run(cmd, cwd=mam_sandbox, capture_output=True, text=True, timeout=150)
|
||||
out = result.stdout + result.stderr
|
||||
|
||||
records = [json.loads(p.read_text()) for p in (mam_sandbox / ".mam/jobs").glob("*.json")]
|
||||
assert [r for r in records if r.get("prompt") == prompt], out
|
||||
jid = next(r["job_id"] for r in records if r.get("prompt") == prompt)
|
||||
brief = mam_sandbox / ".mam/jobs" / jid / "brief.md"
|
||||
assert brief.read_text() == prompt
|
||||
|
||||
calls = [str(c) for c in json.loads(mock_herdr.read_text())["calls"]]
|
||||
injected = [c for c in calls if "agent" in c and "prompt" in c and jid in c]
|
||||
assert injected, f"rc={result.returncode}; no onboarding text reached herdr\n{out[-3000:]}"
|
||||
text = injected[0]
|
||||
assert str(brief) in text and prompt not in text
|
||||
# the #23 ack contract is unchanged: the publish command names this dispatch
|
||||
assert "--expect-dispatch-token" in text and "--event started" in text
|
||||
if strace:
|
||||
assert not [line for line in trace.read_text().splitlines() if "E2BIG" in line]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S9 - the shell loop's delegate_job_safe: a prompt file that cannot be written
|
||||
# is removed, and the cause reaches stderr (callers capture stdout). The real
|
||||
# function is taken from run_loop.sh; the write fails for real (RLIMIT_FSIZE).
|
||||
# From the review of job cc849cbf (codex).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_s9_shell_loop_removes_a_partial_prompt_file_and_says_why(tmp_path):
|
||||
source = (SKILLS / "multi-agent-mux-loop/scripts/run_loop.sh").read_text()
|
||||
function = source[source.index("delegate_job_safe() {"):source.index("\nlog_info() {")]
|
||||
(tmp_path / "input.md").write_text("x" * 2000)
|
||||
harness = tmp_path / "run.sh"
|
||||
harness.write_text('set -euo pipefail\nREPO_ROOT="$PWD"\nMAM_REAL_ROOT="$PWD"\n'
|
||||
'log_error() { printf "%s\\n" "$1"; }\n' + function +
|
||||
'\ntrap "" XFSZ\nulimit -f 1\n'
|
||||
'output=$(delegate_job_safe submit --prompt "$(cat input.md)")\n')
|
||||
result = subprocess.run(["bash", str(harness)], cwd=tmp_path, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode != 0
|
||||
assert "File too large" in result.stderr
|
||||
leftovers = list((tmp_path / ".mam").glob("prompt-shloop-*"))
|
||||
assert not leftovers, [(p.name, p.stat().st_size) for p in leftovers]
|
||||
assert "delegate_job_safe: cannot write the prompt file" in result.stderr
|
||||
Reference in new issue
Block a user