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 <text>` 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 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-09-24 06:08:25 +00:00
1 parent 14d11bf062
commit f26952ccc3
4 files changed
+189 -15

No files matched your search

+22 -4
View File
@@ -2032,22 +2032,36 @@ _delegate_script() {
printf '%s\n' "$(find "$skill_dir" -name "$name" 2>/dev/null | head -n 1 || true)" 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> # delegate_submit_job <prompt> <agent> <agent_session>
# #
# Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout. # Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout.
_delegate_submit_job_legacy() { _delegate_submit_job_legacy() {
local prompt="$1" agent="$2" session="$3" 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)" py_bin="$(_delegate_py_bin)"
registry_py="$(_delegate_script registry.py)" registry_py="$(_delegate_script registry.py)"
if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2 echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
return 1 return 1
fi fi
prompt_file="$(_delegate_prompt_file "$prompt")" || { echo "ERROR: cannot write the prompt file" >&2; return 1; }
"$py_bin" "$registry_py" register \ "$py_bin" "$registry_py" register \
--prompt "$prompt" \ --prompt-file "$prompt_file" \
--agent "$agent" \ --agent "$agent" \
--agent-session "$session" --agent-session "$session" || rc=$?
rm -f "$prompt_file"
return $rc
} }
# delegate_publish_event <job_id> <event> [detail] # 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 echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
return 1 return 1
fi 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] # delegate_publish_event <job_id> <event> [detail]
+8 -2
View File
@@ -26,7 +26,11 @@ def _build_parser() -> argparse.ArgumentParser:
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
p_submit = sub.add_parser("submit", help="register a job; prints the job id") 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", default="claude-code")
p_submit.add_argument("--agent-session", default="herdr:claude") p_submit.add_argument("--agent-session", default="herdr:claude")
p_submit.add_argument("--registry-dir", default=None) 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) args = _build_parser().parse_args(argv)
if args.command == "submit": 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] "--agent", args.agent, "--agent-session", args.agent_session]
if args.registry_dir: if args.registry_dir:
forward = ["--registry-dir", args.registry_dir] + forward forward = ["--registry-dir", args.registry_dir] + forward
@@ -187,14 +187,36 @@ _mam_release_guard() {
# Runs the delegate-job wrapper from the frozen skills tree (REPO_ROOT). # 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), # 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). # 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() { delegate_job_safe() {
local wrapper_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job" local wrapper_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
local rc=0 local rc=0 prompt_file="" args=()
bash "$wrapper_script" "$@" || rc=$? 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 if [ "$rc" -ne 0 ]; then
log_error "delegate_job_safe failed (exit $rc): $wrapper_script" # stderr: callers capture stdout with $(...), and under set -e a failed
log_error " if this loop edits framework skills in place, check that file's syntax:" # submit ends the loop right there - this line is the only cause shown.
log_error " bash -n \"$wrapper_script\"" 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 fi
return $rc return $rc
} }
+132 -4
View File
@@ -303,19 +303,22 @@ def test_s4b_an_exec_error_on_the_busy_retry_is_reported_not_raised(runner, tmp_
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FAKE_AGENT = r""" FAKE_AGENT = r"""
import json, sys import json, os, sys
from pathlib import Path from pathlib import Path
args = sys.argv[3:] # argv[1] = kind to refuse, argv[2] = "submit" args = sys.argv[3:] # argv[1] = kind to refuse, argv[2] = "submit"
opts = dict(zip(args[::2], args[1::2])) opts = dict(zip(args[::2], args[1::2]))
prompt = Path(opts["--prompt-file"]).read_text(encoding="utf-8") prompt = Path(opts["--prompt-file"]).read_text(encoding="utf-8")
refuse = sys.argv[1] 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) jobs.mkdir(parents=True, exist_ok=True)
kind = ("comply" if "재심 결과 기각" in prompt else kind = ("comply" if "재심 결과 기각" in prompt else
"adjudication" if "re-adjudication" in prompt else "adjudication" if "re-adjudication" in prompt else
"arbitration" if "재정(arbitration)" in prompt else "arbitration" if "재정(arbitration)" in prompt else
"correction" if "[이의제기 채널]" in prompt else "correction" if "[이의제기 채널]" in prompt else
opts["--role"]) 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: if kind == refuse:
print(kind + " registration refused", file=sys.stderr) print(kind + " registration refused", file=sys.stderr)
sys.exit(2) sys.exit(2)
@@ -328,13 +331,16 @@ elif kind == "correction":
elif kind == "Reviewer" and "Self-Review" in prompt: elif kind == "Reviewer" and "Self-Review" in prompt:
report = "ok\n\n[VERDICT: PASS]\n" report = "ok\n\n[VERDICT: PASS]\n"
elif kind == "Reviewer": # the first cross review objects, every later one passes 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 n = int(counter.read_text()) + 1 if counter.exists() else 1
counter.write_text(str(n)) 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" report = "line 3 is wrong\n\n[VERDICT: NOT PASS]\n" if n == 1 else "ok\n\n[VERDICT: PASS]\n"
elif kind == "Planner": elif kind == "Planner":
report = "# plan\n\ndo it\n" report = "# plan\n\n" + "p" * int(os.environ.get("FAKE_PLAN_BYTES", "5")) + "\n"
else: 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" report = "done\n"
jid = "j%d" % len(list(jobs.glob("*.json"))) jid = "j%d" % len(list(jobs.glob("*.json")))
(jobs / (jid + ".json")).write_text(json.dumps({"job_id": jid, "status": "completed"})) (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 # 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"}) 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