diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 9df4a15..0655499 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -503,7 +503,20 @@ if [ -n "$DELEGATE_JOB_ID" ]; then # Construct instructions matching multi-agent-mux-delegate-job format py_bin="$(_delegate_py_bin)" - pub="$py_bin .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py --job $DELEGATE_JOB_ID" + # Issue #21: absolute script, registry and audit-log paths (shell-quoted), so + # an agent whose cwd is a git worktree can run the command as written. + pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py" + [ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)" + pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")" + pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}" + pub_reg="${pub_reg:-.mam/jobs}" + mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)" + pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)" + pub="$( + if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi + printf '%q ' "$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$DELEGATE_JOB_ID" + )" + pub="${pub% }" instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation). On start run: $pub --event started. diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index 3259f16..5347d02 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -68,7 +68,42 @@ pick_python() { echo "$py_bin" } -REGISTRY_DIR_DEFAULT=".mam/jobs" +REGISTRY_DIR_DEFAULT="${MAM_JOBS_DIR:-.mam/jobs}" + +# Issue #21: the injected brief path, the start/success/error commands and the +# Output Report Path must work from any cwd - a creator in a git worktree runs +# them from there. Called right after `cd "$WORKDIR"`, so a relative registry +# still resolves against WORKDIR, as before. +_abs_registry_dir() { + mkdir -p "$REGISTRY_DIR" + REGISTRY_DIR="$(cd -P "$REGISTRY_DIR" && pwd -P)" + export SKS_REGISTRY_DIR="$REGISTRY_DIR" +} + +# The audit-log root this wrapper's own subscriber resolves (same resolver), +# pinned into the injected commands so an agent publishing from another cwd +# does not fall back to /.mam/delegate_job_logs. +_logs_dir_abs() { + "$PY" -c 'import os, sys; sys.path.insert(0, sys.argv[1]); from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' "$SCRIPT_DIR/.." +} + +# _pub_cmd : the publish command as one shell-quoted string. +_pub_cmd() { + local s + s="$( + if [ -n "${LOGS_DIR_ABS:-}" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$LOGS_DIR_ABS"; fi + printf '%q ' "$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$JOB_ID" "$@" + )" + printf '%s' "${s% }" +} + +# _instructions [iteration]: the prompt injected into the agent (direct +# and loop jobs share it; test18 pins parts of this wording). +_instructions() { + local pub="$1" iter="${2:-}" brief="$REGISTRY_DIR/$JOB_ID/brief.md" + printf 'Your job_id is "%s". Detailed task requirements, instructions, and target output paths%s are documented in the task brief file at: %s. Please READ and follow %s to complete your work. Commands: start='"'"'%s --event started --heartbeat %s'"'"' (run it FIRST, within %s s; it also starts an automatic heartbeat timer), success='"'"'%s --event completed --detail '"'"', error='"'"'%s --event error --detail '"'"'.' \ + "$JOB_ID" "${iter:+ for iteration $iter}" "$brief" "$brief" "$pub" "$HEARTBEAT_SEC" "$ACK_TIMEOUT" "$pub" "$pub" +} usage() { cat <<'EOF' @@ -150,8 +185,10 @@ cmd_submit() { parse_opts "$@" [[ -n "$PROMPT" ]] || { echo "submit requires --prompt" >&2; exit 1; } PY="$(pick_python)" + [[ "$PY" == */* && "$PY" != /* ]] && PY="$(pwd)/$PY" cd "$WORKDIR" - mkdir -p "$REGISTRY_DIR" + _abs_registry_dir + LOGS_DIR_ABS="$(_logs_dir_abs 2>/dev/null || true)" # 1) register job (prints the new job id) JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \ @@ -175,7 +212,7 @@ cmd_submit() { - **Role**: $DELEGATE_ROLE - **Timeout**: $TIMEOUT s (Idle: $IDLE_TIMEOUT s) - **Liveness contract**: heartbeat every $HEARTBEAT_SEC s (automatic once you publish \`started --heartbeat $HEARTBEAT_SEC\`), unresponsive after $HEARTBEAT_MISSES missed heartbeats, \`started\` expected within $ACK_TIMEOUT s -- **Output Report Path**: .mam/jobs/$JOB_ID/$AGENT-reports/report-final.md +- **Output Report Path**: $REGISTRY_DIR/$JOB_ID/$AGENT-reports/report-final.md ## 🔎 Task Description $PROMPT @@ -195,8 +232,9 @@ EOF 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 --heartbeat $HEARTBEAT_SEC' (run it FIRST, within $ACK_TIMEOUT s; it also starts an automatic heartbeat timer), success='$pub --event completed --detail ', error='$pub --event error --detail '." + local pub instructions + pub="$(_pub_cmd ${pub_extra[@]+"${pub_extra[@]}"})" + instructions="$(_instructions "$pub")" if [[ "$DRY_RUN" == "1" ]]; then run_agent "$JOB_ID" "$instructions" @@ -349,7 +387,7 @@ EOF - **Role**: $display_role - **Iteration**: $iteration - **Liveness contract**: heartbeat every $HEARTBEAT_SEC s (automatic once you publish \`started --heartbeat $HEARTBEAT_SEC\`), unresponsive after $HEARTBEAT_MISSES missed heartbeats, \`started\` expected within $ACK_TIMEOUT s -- **Output Report Path**: .mam/jobs/$JOB_ID/${clean_session}-reports/report-final.md +- **Output Report Path**: $REGISTRY_DIR/$JOB_ID/${clean_session}-reports/report-final.md ## 🔎 Task Description $current_prompt @@ -451,8 +489,9 @@ EOF # 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 --heartbeat $HEARTBEAT_SEC' (run it FIRST, within $ACK_TIMEOUT s; it also starts an automatic heartbeat timer), success='$pub --event completed --detail ', error='$pub --event error --detail '." + local pub instructions + pub="$(_pub_cmd "${loop_pub_extra[@]}")" + instructions="$(_instructions "$pub" "$iteration")" # Trigger agent if ! deliver_prompt "$JOB_ID" "$instructions" "$current_session" "$logf"; then diff --git a/tests/test_issue21_worktree_prereqs.py b/tests/test_issue21_worktree_prereqs.py new file mode 100644 index 0000000..563d329 --- /dev/null +++ b/tests/test_issue21_worktree_prereqs.py @@ -0,0 +1,199 @@ +"""Issue #21 phase-1 prerequisites for the worktree operating model. + + §1 (item 1) delegate-job wrapper: absolute brief / start-command registry / + audit-log root / Output Report Path, runnable from any cwd. + §2 (item 2) loop runner --worktree / --base / --review-only and the + fail-closed empty change-set guard; the control plane stays in the main + checkout. + §3 (item 3) control-plane binding persisted on the session row and forwarded + to the pane; status / reconcile / stop honour MAM_JOBS_DIR. + +Every test writes only under tmp_path / mam_sandbox (Issue #20). +""" + +from __future__ import annotations + +import json +import os +import re +import signal +import subprocess +import sys +import time +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" +RUN_LOOP_SH = SKILLS / "multi-agent-mux-loop" / "scripts" / "run_loop.sh" + +if str(SKILLS) not in sys.path: + sys.path.insert(0, str(SKILLS)) + +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"} + + +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"): + env.pop(k, None) + env.update(UNREACHABLE) + if extra: + env.update({k: str(v) for k, v in extra.items()}) + for k in drop: + env.pop(k, None) + return env + + +def _only_job_id(reg: Path) -> str: + ids = [p.stem for p in reg.glob("*.json")] + assert len(ids) == 1, ids + return ids[0] + + +def _kill_timer(reg: Path, jid: str) -> None: + try: + pid = int((reg / f"{jid}.heartbeat.pid").read_text().strip()) + os.kill(pid, signal.SIGKILL) + except (OSError, ValueError): + pass + + +def _dry_submit(cwd: Path, env: dict, *args) -> subprocess.CompletedProcess: + return subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--prompt", "p21", + "--dry-run", *args], + capture_output=True, text=True, env=env, cwd=str(cwd), timeout=60) + + +def _injected(stdout: str, name: str) -> str: + m = re.search(name + r"='(.*?)'", stdout) + assert m, stdout + return m.group(1) + + +# --------------------------------------------------------------------------- +# §1 item 1 — the wrapper's injected paths are absolute +# --------------------------------------------------------------------------- + +def test_t1_1_injected_paths_are_absolute(tmp_path): + a = tmp_path / "A" + a.mkdir() + real_a = os.path.realpath(a) + env = _env(tmp_path, {"WORKSPACE_ROOT": a, "DELEGATE_JOB_LOGS_DIR": a / "logs"}) + res = _dry_submit(a, env, "--workdir", str(a)) + assert res.returncode == 0, res.stderr + jid = _only_job_id(a / ".mam" / "jobs") + brief = f"{real_a}/.mam/jobs/{jid}/brief.md" + assert res.stdout.count(brief) == 2, res.stdout + start = _injected(res.stdout, "start") + assert f"--registry-dir {real_a}/.mam/jobs " in start + assert start.startswith(f"DELEGATE_JOB_LOGS_DIR={real_a}/logs "), start + # no cwd-relative registry path is left anywhere in the injected text + assert res.stdout.count(f".mam/jobs/{jid}") == res.stdout.count(f"{real_a}/.mam/jobs/{jid}") + + +def test_t1_2_start_and_success_commands_run_from_another_cwd(tmp_path): + """The core regression: a registry and an audit-log root with a space in + them, the commands executed as written from a different cwd with no + workspace env - the job advances, the audit trail stays in the control + root, and nothing is written under the other cwd.""" + ctl = tmp_path / "ctl root" + reg, logs, wt = ctl / "jobs", ctl / "logs", tmp_path / "wt" + for d in (reg, logs, wt): + d.mkdir(parents=True) + env = _env(tmp_path, {"WORKSPACE_ROOT": ctl, "DELEGATE_JOB_LOGS_DIR": logs}) + res = _dry_submit(ctl, env, "--registry-dir", str(reg), "--workdir", str(ctl), + "--heartbeat", "7", "--ack-timeout", "11") + assert res.returncode == 0, res.stderr + jid = _only_job_id(reg) + start = _injected(res.stdout, "start") + success = _injected(res.stdout, "success").replace("", "done") + agent_env = _env(tmp_path) # no WORKSPACE_ROOT / MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR + try: + r1 = subprocess.run(["bash", "-c", start + " --attempts 1"], capture_output=True, text=True, + env=agent_env, cwd=str(wt), timeout=120) + # rc 2 = broker unreachable; the event is recorded locally first (test18 contract) + assert r1.returncode == 2, r1.stderr + assert json.loads((reg / f"{jid}.json").read_text())["status"] != "pending" + r2 = subprocess.run(["bash", "-c", success + " --attempts 1"], capture_output=True, text=True, + env=agent_env, cwd=str(wt), timeout=120) + assert r2.returncode == 2, r2.stderr + assert json.loads((reg / f"{jid}.json").read_text())["status"] == "completed" + finally: + _kill_timer(reg, jid) + audit = [json.loads(l) for l in (logs / jid / "events.ndjson").read_text().splitlines() if l.strip()] + published = [json.dumps(e) for e in audit if e.get("event") == "published"] + assert len(published) == 2, audit + assert '"started"' in published[0] and '"completed"' in published[1], published + assert not (wt / ".mam").exists(), "the agent's cwd must not grow a .mam" + + +def test_t1_3_mam_jobs_dir_is_the_default_registry(tmp_path): + x, y = tmp_path / "X", tmp_path / "Y" + env = _env(tmp_path, {"MAM_JOBS_DIR": x, "WORKSPACE_ROOT": tmp_path, "DELEGATE_JOB_LOGS_DIR": tmp_path / "logs"}) + res = _dry_submit(tmp_path, env, "--workdir", str(tmp_path)) + assert res.returncode == 0, res.stderr + assert len(list(x.glob("*.json"))) == 1 + assert not (tmp_path / ".mam" / "jobs").exists() + + res = _dry_submit(tmp_path, env, "--workdir", str(tmp_path), "--registry-dir", str(y)) + assert res.returncode == 0, res.stderr + assert len(list(y.glob("*.json"))) == 1, "an explicit --registry-dir wins over MAM_JOBS_DIR" + assert len(list(x.glob("*.json"))) == 1 + + +def test_t1_4_wrapper_source_guard(): + """The loop job type never builds its text under --dry-run (it returns + early), so both paths are pinned to the shared helpers here.""" + src = WRAPPER.read_text() + assert ".mam/jobs/$JOB_ID" not in src + assert src.count('instructions="$(_instructions "$pub")"') == 1 + assert src.count('instructions="$(_instructions "$pub" "$iteration")"') == 1 + assert src.count('pub="$(_pub_cmd ') == 2 + report_lines = [l for l in src.splitlines() if "**Output Report Path**" in l] + assert len(report_lines) == 2 and all("$REGISTRY_DIR/$JOB_ID/" in l for l in report_lines) + + +def _sent_texts(state) -> list: + out = [] + + def walk(o): + if isinstance(o, dict): + for k, v in o.items(): + if k == "sent_text" and isinstance(v, str): + out.append(v) + else: + walk(v) + elif isinstance(o, list): + for v in o: + walk(v) + walk(state) + return out + + +def test_t1_5_onboarding_publish_command_is_absolute(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + script = tmp / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + res = subprocess.run(["bash", str(script), "--workspace", str(tmp), "--agent", "claude", + "--role", "creator", "--onboard"], + capture_output=True, text=True, cwd=str(tmp), timeout=180) + assert res.returncode == 0, res.stdout + res.stderr + texts = [t for t in _sent_texts(json.loads(Path(mock_herdr).read_text())) if "publish_event.py" in t] + assert texts, "the onboarding instructions were not injected" + text = "".join(texts) + real = os.path.realpath(tmp) + assert f" {real}/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py " in text, text + assert f"--registry-dir {real}/.mam/jobs " in text, text + assert f"DELEGATE_JOB_LOGS_DIR={real}/.mam/delegate_job_logs " in text, text + assert " .agents/skills/" not in text +