From f26952ccc3e2016273e54a3f71fb1dfa3e641d90 Mon Sep 17 00:00:00 2001 From: Godopu Date: Thu, 24 Sep 2026 06:08:25 +0000 Subject: [PATCH] fix(issue-36): the legacy shell loop and lib.sh's submit helpers pass prompts by file The two remaining exec points that carried a prompt as one argv string (capped at 131,072 bytes): - run_loop.sh (MAM_LOOP_PY=0): delegate_job_safe, which all eleven shell delegations go through, turns `--prompt ` into `--prompt-file` under the control root's .mam/ and removes the file once the wrapper returns. Its failure lines go to stderr: callers capture stdout, and under set -e a failed submit ends the loop at that line with the wrapper's code, so the cause was being swallowed. - lib.sh delegate_submit_job and its legacy twin (create_session's onboarding and --submit-job): the prompt is written under .mam/ and handed on as --prompt-file; lib_py.delegation.cli submit accepts --prompt-file and forwards it to the registry. --prompt keeps working everywhere. S6/S6b push a 140,000-byte prompt through the CLI and both helpers; S7 runs the real run_loop.sh with a 140,000-byte plan and a 150,000-byte diff; S7b fixes a nonzero exit when the implementation or compliance job cannot be registered. All six fail on 14d11bf. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 26 +++- .agents/skills/lib_py/delegation/cli.py | 10 +- .../multi-agent-mux-loop/scripts/run_loop.sh | 32 ++++- tests/test_issue36_prompt_transport.py | 136 +++++++++++++++++- 4 files changed, 189 insertions(+), 15 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index c595e21..11e014e 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2032,22 +2032,36 @@ _delegate_script() { printf '%s\n' "$(find "$skill_dir" -name "$name" 2>/dev/null | head -n 1 || true)" } +# _delegate_prompt_file — 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 # # 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 [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 [detail] diff --git a/.agents/skills/lib_py/delegation/cli.py b/.agents/skills/lib_py/delegation/cli.py index 0a9a634..27f860b 100644 --- a/.agents/skills/lib_py/delegation/cli.py +++ b/.agents/skills/lib_py/delegation/cli.py @@ -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 diff --git a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh index c5cd555..6be7fdb 100644 --- a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh @@ -187,14 +187,36 @@ _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 ` argument is handed to the wrapper as +# `--prompt-file ` 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" \ + || { log_error "delegate_job_safe: cannot write the prompt file under $MAM_REAL_ROOT/.mam"; 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 } diff --git a/tests/test_issue36_prompt_transport.py b/tests/test_issue36_prompt_transport.py index a208136..81312c7 100644 --- a/tests/test_issue36_prompt_transport.py +++ b/tests/test_issue36_prompt_transport.py @@ -303,19 +303,22 @@ def test_s4b_an_exec_error_on_the_busy_retry_is_reported_not_raised(runner, tmp_ # --------------------------------------------------------------------------- FAKE_AGENT = r""" -import json, sys +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["--registry-dir"]) +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) @@ -328,13 +331,16 @@ elif kind == "correction": 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.cwd() / "review-count" + 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\ndo it\n" + 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"})) @@ -409,3 +415,125 @@ def test_s5_a_diff_in_the_former_blind_spot_is_delivered_whole(tmp_path): # 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