From c29c5b2142c915876eb6a45b97d8fa046c2427a5 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sat, 10 Oct 2026 15:39:21 +0000 Subject: [PATCH] feat(issue-50): create --onboard waits for the onboarding job to end (A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handshake only said the agent saw the prompt (#23). create_session.sh now waits, after `started`, for the onboarding job to reach a terminal status and makes that its own result, so the orchestrator learns it from create's exit (MULTI_AGENT_RULES §6) instead of guessing from herdr's agent_status: - lib_py/delegation/wrapper.py: `wait-terminal` (completed|error|cancelled, `timeout` after --timeout, `missing` when the record is gone; stops with its caller via --watch-pid) and `onboard-exit` (completed 0, error or cancelled 44, timeout or missing 45). bash branches on the printed answer. - lib.sh: delegate_wait_terminal / delegate_onboard_exit, thin calls. - create_session.sh: `onboarding completed: job (s)` on stdout, or `last_visible_status: onboarding-` on the row, the reason on stderr and `exit 44` / `exit 45` after `trap - EXIT` - the session is kept, as for a missing handshake. The "no started" path (error, exit 0 before) now ends with 44. MAM_ONBOARD_WAIT_S (default 600, floor 1) bounds the wait. - worktree_prepare.sh: 44/45 pass through with "session and worktree kept". - create SKILL.md exit table (44, 45) and the knob; the #42 inventory follows. - tests/conftest.py: --onboard is the default, so ~60 sandbox create tests reach the new wait. The mock herdr now acts as the agent for create's onboarding prompt - it runs the publish commands the prompt names through the real publish_event.py - and MAM_ONBOARD_WAIT_S=3 caps a test that turns that off (MAM_MOCK_ONBOARD_HANDSHAKE=0 -> 44, `started` -> 45). The delegate-job wrapper's prompt has another shape and is never answered. - tests/test_issue50_onboard.py: the decision tables, create in the sandbox (0 / 44 / 45, session kept, row note, trap window), the knob's integer rule, worktree_prepare's pass-through. Co-Authored-By: Claude Fable 5.1 --- .agents/skills/lib.sh | 17 ++ .agents/skills/lib_py/delegation/wrapper.py | 47 ++++ .../skills/multi-agent-mux-create/SKILL.md | 13 +- .../scripts/create_session.sh | 43 ++++ .../scripts/worktree_prepare.sh | 9 +- tests/conftest.py | 29 +++ tests/data/skill_code_blocks.json | 20 +- tests/test_issue42_skill_blocks.py | 2 +- tests/test_issue50_onboard.py | 201 ++++++++++++++++++ 9 files changed, 367 insertions(+), 14 deletions(-) create mode 100644 tests/test_issue50_onboard.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index a942567..cd6704e 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2381,6 +2381,23 @@ delegate_wait_started() { done } +# delegate_wait_terminal +# Issue #50: prints how the job ended - completed|error|cancelled - or +# `timeout` when it has not ended after , or `missing` when its +# record is gone. The decision is lib_py.delegation.wrapper wait-terminal. +delegate_wait_terminal() { + local job_id="$1" timeout_s="$2" + local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}" + PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.wrapper wait-terminal \ + --job "$job_id" --registry-dir "$reg_dir" --timeout "$timeout_s" --watch-pid "$$" +} + +# delegate_onboard_exit +# Issue #50: create's exit code for an onboarding status (0, 44 or 45). +delegate_onboard_exit() { + PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.wrapper onboard-exit "$1" +} + # ensure_subscriber_daemon [workdir] # Start the monitor subscriber for this workspace unless one is already running. # Prints the PID(s) on stdout, exactly as start_watchdog always did. diff --git a/.agents/skills/lib_py/delegation/wrapper.py b/.agents/skills/lib_py/delegation/wrapper.py index 0d4ac6f..3e2043f 100644 --- a/.agents/skills/lib_py/delegation/wrapper.py +++ b/.agents/skills/lib_py/delegation/wrapper.py @@ -18,6 +18,12 @@ no answer) cannot pass for "no". The wrapper uses --print. finalize-begin / finalize-end ... finalize_unfinished, split around the one herdr diagnostic probe bash still runs +Issue #50 (create --onboard waits for the onboarding to end): + wait-terminal --job J --registry-dir R --timeout T [--watch-pid P] + prints the job's terminal status, or + `timeout` / `missing` + onboard-exit create's exit code for it: 0, 44, 45 + finalize-end answers on fd 3 (stdout when fd 3 is closed): its stdout carries registry.py's close output to the wrapper's stdout, as before. It runs as a child of the wrapper and must not outlive it (review R1): with --watch-pid it @@ -48,6 +54,10 @@ SUB_RC_COMPLETED, SUB_RC_ERROR, SUB_RC_NO_EVENT, SUB_RC_BROKER_UNAVAILABLE, SUB_ TERMINAL = ("completed", "error", "cancelled") EXIT_CODES = {"completed": 0, "error": 1, "timeout": 2, "broker_unavailable": 3, "cancelled": 5} +# Issue #50: create --onboard's exit code for how the onboarding job ended. +# 44 = the agent ended it with error (or it was cancelled), 45 = it did not end +# within MAM_ONBOARD_WAIT_S (or its record is gone). The session is kept either way. +ONBOARD_EXIT_CODES = {"completed": 0, "error": 44, "cancelled": 44, "timeout": 45, "missing": 45} def _now() -> int: @@ -169,6 +179,30 @@ def _tail(path: str, n: int) -> List[str]: return fh.read().splitlines()[-n:] +# --------------------------------------------------------------------------- +# Issue #50 A: create --onboard waits for the onboarding job to end +# --------------------------------------------------------------------------- + +def onboard_exit(status: str) -> int: + return ONBOARD_EXIT_CODES.get(status, 45) + + +def wait_terminal(a: argparse.Namespace) -> str: + """The job's terminal status once it has one; `timeout` after --timeout s, + `missing` when the record is gone. Reads the disk every second.""" + deadline = _now() + a.timeout + record = os.path.join(a.registry_dir, f"{a.job}.json") + while True: + disk = disk_status(a.registry_dir, a.job) + if disk in TERMINAL: + return disk + if not os.path.isfile(record): + return "missing" + if _now() >= deadline: + return "timeout" + _sleep(a, 1) + + def finalize_begin(a: argparse.Namespace) -> str: """`keep` (FINAL_JOB_STATUS untouched), `done `, or `go|probe `.""" @@ -307,6 +341,12 @@ def _parser() -> argparse.ArgumentParser: s.add_argument("session") s.add_argument("agent") s.add_argument("--print", action="store_true", help="print yes/no and exit 0") + s = sub.add_parser("wait-terminal") + s.add_argument("--job", required=True) + s.add_argument("--registry-dir", required=True) + s.add_argument("--timeout", type=int, required=True) + s.add_argument("--watch-pid", type=int, default=None, help="the caller's pid; gone -> stop waiting") + sub.add_parser("onboard-exit").add_argument("status") for name in ("finalize-begin", "finalize-end"): s = sub.add_parser(name) s.add_argument("--job", required=True) @@ -353,6 +393,13 @@ def main(argv: Optional[List[str]] = None) -> int: return _yes_no(stall_watch(a.session, a.agent), a.print) elif a.cmd == "finalize-begin": print(finalize_begin(a)) + elif a.cmd == "wait-terminal": + try: + print(wait_terminal(a)) + except OwnerGone: + print(f"wait-terminal: caller pid {a.watch_pid} is gone; stopped waiting on job {a.job}", file=sys.stderr) + elif a.cmd == "onboard-exit": + print(onboard_exit(a.status)) else: try: _answer(finalize_end(a)) diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index ef87c94..11c6416 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -91,7 +91,12 @@ The bootstrapped server receives a **sanitized copy of the caller's environment* this script applies to a TUI that never signalled ready). Re-send with `multi-agent-mux-delegate-job submit` once the agent is up; the prompt is never re-injected automatically. `MAM_ONBOARD_ACK_S` overrides how long that - wait is — distinct from the wrapper's `MAM_INJECT_ACK_GRACE_S`. + wait is — distinct from the wrapper's `MAM_INJECT_ACK_GRACE_S`. After the + handshake the script waits for the onboarding job to **end** (Issue #50, + `MAM_ONBOARD_WAIT_S`, default 600 s) and prints `onboarding completed: job + (s)`; an onboarding that ends with `error` exits 44 and one that does not + end in time exits 45, both keeping the session, with + `last_visible_status: onboarding-` on the row. **Start-up dialogs** are answered for every agent, not only claude (#24). The blocking-modal signatures and their accept keys live in each agent adapter @@ -171,6 +176,8 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace | `--auto-trust` / `--no-auto-trust` | Answer a start-up directory-trust dialog or leave it to a human (`MAM_AUTO_TRUST`; default: answer) | | `--isolate` / `--no-isolate` | Legacy, no-op | +`MAM_ONBOARD_WAIT_S` (default 600): how long `--onboard` waits for the onboarding job to end after the `started` handshake. + | Exit | Meaning | |---|---| | 0 | Created; or `--dry-run` | @@ -180,6 +187,8 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace | 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back | | 42 | The TUI never became ready and the process is gone, or it is alive and `MAM_KILL_ON_READY_TIMEOUT=1` (rolled back) | | 43 | The rollback could not kill the herdr session; it is still running | +| 44 | The onboarding job ended with `error` (or was cancelled); the session is kept (Issue #50) | +| 45 | The onboarding job did not end within `MAM_ONBOARD_WAIT_S`, or its record is gone; the session is kept | | other | A later step failed and its code is passed through. The EXIT trap (`cleanup_herdr_on_error`) rolls back the herdr session and closes the onboarding job as `cancelled`; if that kill fails, the exit is 43 instead | ## Registering the session in agent-sessions.yaml @@ -201,7 +210,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \ - `--creator-agent` creates the creator through this skill with the control root's binding and `MAM_NO_SUBSCRIBER_DAEMON=1`; the row's `pane.cwd` is the worktree. - Prints `BASE=`: record it **before** delegating the implementation, then review with `run_loop.sh --worktree --base "$BASE" --review-only`. - A submodule path that is not initialised yet but already holds files refuses `--submodules` up front (exit 3, nothing changed): the clone would fail on it and a rollback could not tell those files from its own. -- Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, 3 refused to protect existing content (nothing changed). +- Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, 3 refused to protect existing content (nothing changed); 44/45 are create's (onboarding error / timeout with `--onboard`; session and worktree kept). - create itself: a control-plane binding that cannot be computed is refused (exit 2) for a `--workspace` other than the control root and warned about for the control root; the immediate reconcile and the watchdog run the control root's code. ## Pitfalls 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 bd62e27..b8ad613 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -19,6 +19,8 @@ # 4 = agent-sessions.yaml append failure # 42 = agent TUI never became ready and the process is gone # 43 = rollback could not kill the herdr session; it is still running (#23) +# 44 = the onboarding job ended with error (or was cancelled); the session is kept (#50) +# 45 = the onboarding job did not end within MAM_ONBOARD_WAIT_S; the session is kept (#50) set -euo pipefail _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -316,6 +318,21 @@ for s in d.get('herdr_sessions', []): PYEOF } +# _mark_row_note +# Issue #50: last_visible_status only. The row stays `running` with its +# delegate_job_id: the session is alive and the job record is the evidence. +_mark_row_note() { + local note="$1" + [ "${ROW_APPENDED:-0}" = "1" ] || return 0 + atomic_dump_yaml "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" ROW_NOTE="$note" <<'PYEOF' || true +name = os.environ['SESSION_NAME'] +for s in d.get('herdr_sessions', []): + if s.get('name') == name: + s['last_visible_status'] = os.environ['ROW_NOTE'] + break +PYEOF +} + # Trap for rolling back/cleaning up herdr session if script exits due to error cleanup_herdr_on_error() { local exit_code=$? @@ -698,12 +715,38 @@ If you delegate any part of it with multi-agent-mux-delegate-job submit, add --p esac JOB_TERMINALIZED=1 fi + # Issue #50: the handshake only said the agent saw the prompt. Wait for the + # onboarding itself to end; its result is this script's exit code, so the + # orchestrator learns it from create (MULTI_AGENT_RULES §6) instead of + # guessing from herdr's agent_status. A job marked `error` just above is + # terminal and returns at once. + ONBOARD_WAIT_S=$(mam_int_knob MAM_ONBOARD_WAIT_S 600 1) + ONBOARD_T0=$(date +%s) + ONBOARD_STATUS="$(delegate_wait_terminal "$DELEGATE_JOB_ID" "$ONBOARD_WAIT_S")" + ONBOARD_STATUS="${ONBOARD_STATUS:-missing}" # Trigger immediate priority reconcile cycle asynchronously # Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the # (unreviewed) copy in a worktree --workspace; identical paths by default. (bash "$SKILL_DIR/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE_ROOT") echo "watchdog PID: $WD_PID" + case "$ONBOARD_STATUS" in + completed) + echo "onboarding completed: job $DELEGATE_JOB_ID ($(( $(date +%s) - ONBOARD_T0 ))s)" + ;; + *) + # The session is kept, as for a missing handshake above: the row says + # what happened and the exit code tells the caller. The trap is cleared + # first - a non-zero exit must not roll a live session back. + _mark_row_note "onboarding-$ONBOARD_STATUS" + echo "ERROR: onboarding $ONBOARD_STATUS for '$SESSION_NAME': job $DELEGATE_JOB_ID; the session is kept" >&2 + trap - EXIT + case "$(delegate_onboard_exit "$ONBOARD_STATUS")" in + 44) exit 44 ;; + *) exit 45 ;; + esac + ;; + esac fi # Clear cleanup trap as registration was fully successful trap - EXIT diff --git a/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh b/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh index fd16030..fc81607 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh @@ -15,7 +15,8 @@ # creator session registered in the control root's .mam -> summary with BASE=. # # Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, -# 3 refused to protect existing content (nothing changed). +# 3 refused to protect existing content (nothing changed); 44/45 are create's +# (onboarding error / timeout; session and worktree kept, Issue #50). set -euo pipefail _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -239,7 +240,11 @@ if [ -n "$CREATOR_AGENT" ]; then rc=0 env "${env_add[@]}" bash "$SKILL_DIR/multi-agent-mux-create/scripts/create_session.sh" "${args[@]}" || rc=$? if [ "$rc" -ne 0 ]; then - echo "ERROR: create_session.sh failed (rc=$rc); the worktree is kept - remove it with worktree_remove.sh --path '$WT'" >&2 + case "$rc" in + 44) echo "ERROR: onboarding error for '$SESSION' (create_session.sh rc=44); session and worktree kept" >&2 ;; + 45) echo "ERROR: onboarding timeout for '$SESSION' (create_session.sh rc=45); session and worktree kept" >&2 ;; + *) echo "ERROR: create_session.sh failed (rc=$rc); the worktree is kept - remove it with worktree_remove.sh --path '$WT'" >&2 ;; + esac exit "$rc" fi fi diff --git a/tests/conftest.py b/tests/conftest.py index 8099b8c..dcae90c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -281,6 +281,12 @@ def mam_sandbox(tmp_path, monkeypatch, request): # Create-specific on purpose: MAM_INJECT_ACK_GRACE_S is the delegate-job # wrapper's knob and its own tests assert on the value they pass. monkeypatch.setenv("MAM_ONBOARD_ACK_S", "3") + # Issue #50: create now waits for the onboarding job to end as well. The + # mock herdr acts as the agent for create's onboarding prompt (it runs the + # publish commands the prompt names, through the real publish_event.py), + # so the wait ends at once; this cap is for a test that turns that off + # (MAM_MOCK_ONBOARD_HANDSHAKE=0 / started) and expects 44 / 45. + monkeypatch.setenv("MAM_ONBOARD_WAIT_S", "3") monkeypatch.delenv("HERDR_SESSION_NAME", raising=False) monkeypatch.delenv("HERDR_SERVER_NAME", raising=False) monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False) @@ -356,6 +362,27 @@ if os.path.exists(state_file): pass time.sleep(0.02) +def _onboard_handshake(text): + # Issue #50: create's onboarding prompt names the publish commands it + # expects the agent to run. This fake agent runs them - through the real + # publish_event.py, as a real agent would - so create's wait for the + # onboarding to end is satisfied. MAM_MOCK_ONBOARD_HANDSHAKE=0 publishes + # nothing (create exits 44: no started), `started` publishes only the + # first (create exits 45: never completed). The delegate-job wrapper's + # prompt has another shape and is never answered here. + import re + import subprocess + mode = os.environ.get("MAM_MOCK_ONBOARD_HANDSHAKE", "1") + if mode == "0" or "On start run:" not in text: + return + for label in ("On start run:", "On success run:"): + m = re.search(r"^" + re.escape(label) + r"\\s+(.+?)\\.?\\s*$", text, re.M) + if m: + subprocess.run(["bash", "-c", m.group(1)], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=120) + if mode == "started": + break + # Record the command call state.setdefault("calls", []).append(sys.argv[1:]) try: @@ -659,6 +686,7 @@ elif cmd1 == "pane": sys.exit(1) pane_id = args[2] text = args[3] + _onboard_handshake(text) found = False for a_name, data in state.get("agents", {}).items(): if data.get("pane_id") == pane_id or a_name == pane_id: @@ -1147,6 +1175,7 @@ elif cmd1 == "agent": target_agent = agents[matched_k] target_agent["sent_text"] = target_agent.get("sent_text", "") + text target_agent["buffer"] = target_agent.get("buffer", "") + "\\n" + text + "\\n\\nesc to interrupt" + _onboard_handshake(text) if wait_flag: p_res = state.get("prompt_wait_result") if p_res is None: diff --git a/tests/data/skill_code_blocks.json b/tests/data/skill_code_blocks.json index e7c84e7..541a1a8 100644 --- a/tests/data/skill_code_blocks.json +++ b/tests/data/skill_code_blocks.json @@ -60,8 +60,8 @@ }, { "skill": "multi-agent-mux-create", - "start": 129, - "end": 147, + "start": 134, + "end": 152, "lang": "yaml", "sha": "2dd608d04ae0", "class": "c", @@ -74,8 +74,8 @@ }, { "skill": "multi-agent-mux-create", - "start": 153, - "end": 155, + "start": 158, + "end": 160, "lang": "bash", "sha": "613003708f19", "class": "c", @@ -88,8 +88,8 @@ }, { "skill": "multi-agent-mux-create", - "start": 193, - "end": 197, + "start": 202, + "end": 206, "lang": "bash", "sha": "38836d31bf38", "class": "c", @@ -103,8 +103,8 @@ }, { "skill": "multi-agent-mux-create", - "start": 219, - "end": 241, + "start": 228, + "end": 250, "lang": "bash", "sha": "d90cfd2555fd", "class": "b", @@ -612,10 +612,12 @@ "4", "42", "43", + "44", + "45", "other" ], "passthrough": { - "4": "multi-agent-mux-create/scripts/create_session.sh:515" + "4": "multi-agent-mux-create/scripts/create_session.sh:532" } } }, diff --git a/tests/test_issue42_skill_blocks.py b/tests/test_issue42_skill_blocks.py index 2d57727..07d5f9a 100644 --- a/tests/test_issue42_skill_blocks.py +++ b/tests/test_issue42_skill_blocks.py @@ -218,7 +218,7 @@ def test_literal_exit_rule_on_base_scripts(): and stop's `sys.exit(7)` are not counted.""" assert literal_exit_codes("multi-agent-mux-resume/scripts/resume_session.sh") == {"0", "1", "2"} assert literal_exit_codes("multi-agent-mux-stop/scripts/stop_session.sh") == {"0", "1", "2", "3", "4"} - assert literal_exit_codes("multi-agent-mux-create/scripts/create_session.sh") == {"0", "1", "2", "3", "42", "43"} + assert literal_exit_codes("multi-agent-mux-create/scripts/create_session.sh") == {"0", "1", "2", "3", "42", "43", "44", "45"} # 5. Evidence exists. diff --git a/tests/test_issue50_onboard.py b/tests/test_issue50_onboard.py new file mode 100644 index 0000000..dc25d48 --- /dev/null +++ b/tests/test_issue50_onboard.py @@ -0,0 +1,201 @@ +"""Issue #50 A: `create --onboard` waits for the onboarding job to end. + +- U: the decisions (lib_py.delegation.wrapper wait-terminal / onboard-exit). +- S: create_session.sh in the sandbox (mock herdr whose fake agent answers + the onboarding prompt through the real publish_event.py): completed -> 0 + and a one-line result; error -> 44; never ended -> 45; the session is kept + and the row says why; worktree_prepare.sh passes 44/45 through. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +SKILLS = REPO_ROOT / ".agents" / "skills" +if str(SKILLS) not in sys.path: + sys.path.insert(0, str(SKILLS)) + +from lib_py.delegation import registry, wrapper as decide # noqa: E402 + + +# --------------------------------------------------------------------------- +# U - decisions +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("status,code", [("completed", 0), ("error", 44), ("cancelled", 44), + ("timeout", 45), ("missing", 45), ("", 45), ("garbage", 45)]) +def test_u1_onboard_exit_table(status, code): + assert decide.onboard_exit(status) == code + + +def _wait_terminal(reg, job, timeout=1, watch_pid=None): + cmd = [sys.executable, "-m", "lib_py.delegation.wrapper", "wait-terminal", "--job", job, + "--registry-dir", str(reg), "--timeout", str(timeout)] + if watch_pid is not None: + cmd += ["--watch-pid", str(watch_pid)] + return subprocess.run(cmd, capture_output=True, text=True, cwd=str(SKILLS), + env=dict(os.environ, PYTHONPATH=str(SKILLS)), timeout=60) + + +@pytest.mark.parametrize("terminal", ["completed", "error", "cancelled"]) +def test_u2_wait_terminal_prints_the_terminal_status_at_once(tmp_path, terminal): + jid = registry.register_job(prompt="onboarding", agent="claude-code", agent_session="herdr:s", + registry_dir=str(tmp_path)) + rec = json.loads((tmp_path / f"{jid}.json").read_text()) + rec["status"] = terminal + (tmp_path / f"{jid}.json").write_text(json.dumps(rec)) + t0 = time.time() + res = _wait_terminal(tmp_path, jid, timeout=30) + assert res.stdout.strip() == terminal and res.returncode == 0, res.stderr + assert time.time() - t0 < 10 + + +def test_u2b_wait_terminal_times_out_on_a_job_that_never_ends(tmp_path): + jid = registry.register_job(prompt="onboarding", agent="claude-code", agent_session="herdr:s", + registry_dir=str(tmp_path)) + res = _wait_terminal(tmp_path, jid, timeout=1) + assert res.stdout.strip() == "timeout", res.stdout + res.stderr + + +def test_u2c_wait_terminal_reports_a_missing_record(tmp_path): + res = _wait_terminal(tmp_path, "nothere", timeout=30) + assert res.stdout.strip() == "missing" + + +def test_u2d_wait_terminal_stops_with_its_caller(tmp_path): + jid = registry.register_job(prompt="onboarding", agent="claude-code", agent_session="herdr:s", + registry_dir=str(tmp_path)) + gone = subprocess.Popen(["true"]) + gone.wait() + res = _wait_terminal(tmp_path, jid, timeout=30, watch_pid=gone.pid) + assert res.stdout == "" and "is gone" in res.stderr, res.stdout + res.stderr + + +# --------------------------------------------------------------------------- +# S - create_session.sh +# --------------------------------------------------------------------------- + +def _create(sandbox, *extra, env=None): + script = sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + cmd = ["bash", str(script), "--workspace", str(sandbox), "--agent", "claude", "--role", "creator", + "--session", "t50-creator-claude", "--onboard", *extra] + return subprocess.run(cmd, capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=300) + + +def _row(sandbox): + (row,) = yaml.safe_load((sandbox / ".mam" / "agent-sessions.yaml").read_text())["herdr_sessions"] + return row + + +def _job(sandbox, jid): + return json.loads((sandbox / ".mam" / "jobs" / f"{jid}.json").read_text()) + + +def _mock_state(mock_herdr): + return json.loads(Path(mock_herdr).read_text()) + + +def test_s1_create_ends_with_the_onboarding_completed(mam_sandbox, mock_herdr, mock_agents): + res = _create(mam_sandbox) + assert res.returncode == 0, res.stdout + res.stderr + row = _row(mam_sandbox) + jid = row["delegate_job_id"] + assert f"onboarding completed: job {jid} (" in res.stdout, res.stdout + assert _job(mam_sandbox, jid)["status"] == "completed" + assert not str(row.get("last_visible_status", "")).startswith("onboarding-") + assert "onboarding" not in res.stderr.lower() or "WARNING" not in res.stderr + + +def test_s2_an_onboarding_that_ends_with_error_exits_44_and_keeps_the_session(mam_sandbox, mock_herdr, mock_agents, + monkeypatch): + monkeypatch.setenv("MAM_MOCK_ONBOARD_HANDSHAKE", "0") # no agent answer: no started -> error + res = _create(mam_sandbox) + assert res.returncode == 44, res.stdout + res.stderr + row = _row(mam_sandbox) + jid = row["delegate_job_id"] + assert jid, row + assert f"onboarding error for 't50-creator-claude': job {jid}; the session is kept" in res.stderr, res.stderr + assert _job(mam_sandbox, jid)["status"] == "error" + assert row["status"] == "running" and row["last_visible_status"] == "onboarding-error" + state = _mock_state(mock_herdr) + assert "t50-creator-claude" in state["agents"], "the session was rolled back" + assert not any(c[:1] == ["kill-session"] for c in state["calls"]), "the EXIT trap ran" + assert "onboarding completed" not in res.stdout + + +def test_s3_an_onboarding_that_never_ends_exits_45_and_keeps_the_session(mam_sandbox, mock_herdr, mock_agents, + monkeypatch): + monkeypatch.setenv("MAM_MOCK_ONBOARD_HANDSHAKE", "started") # started, never completed + t0 = time.time() + res = _create(mam_sandbox) + assert res.returncode == 45, res.stdout + res.stderr + row = _row(mam_sandbox) + jid = row["delegate_job_id"] + assert f"onboarding timeout for 't50-creator-claude': job {jid}; the session is kept" in res.stderr, res.stderr + assert _job(mam_sandbox, jid)["status"] == "running" + assert row["status"] == "running" and row["last_visible_status"] == "onboarding-timeout" + assert "t50-creator-claude" in _mock_state(mock_herdr)["agents"] + assert time.time() - t0 < 120, "MAM_ONBOARD_WAIT_S=3 bounds the wait in the sandbox" + + +def test_s3b_the_wait_knob_follows_the_integer_rule(mam_sandbox, mock_herdr, mock_agents, monkeypatch): + monkeypatch.setenv("MAM_MOCK_ONBOARD_HANDSHAKE", "started") + monkeypatch.setenv("MAM_ONBOARD_WAIT_S", "0") # below the floor of 1: the default (600) with a warning + monkeypatch.setenv("MAM_ONBOARD_ACK_S", "2") + script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + src = script.read_text() + # bound the run: the default would wait 600 s; cap the python wait by a stub of the knob call + assert 'ONBOARD_WAIT_S=$(mam_int_knob MAM_ONBOARD_WAIT_S 600 1)' in src + script.write_text(src.replace('ONBOARD_WAIT_S=$(mam_int_knob MAM_ONBOARD_WAIT_S 600 1)', + 'ONBOARD_WAIT_S=$(mam_int_knob MAM_ONBOARD_WAIT_S 600 1); echo "KNOB=$ONBOARD_WAIT_S"; ONBOARD_WAIT_S=1')) + res = _create(mam_sandbox) + assert "KNOB=600" in res.stdout, res.stdout + assert res.returncode == 45 + + +def test_s4_the_source_clears_the_trap_before_the_named_exits(): + src = (SKILLS / "multi-agent-mux-create" / "scripts" / "create_session.sh").read_text() + tail = src.split('_mark_row_note "onboarding-$ONBOARD_STATUS"')[1].split("esac")[0] + assert tail.index("trap - EXIT") < tail.index("exit 44") < tail.index("exit 45") + assert "delegate_wait_terminal" in src and "delegate_onboard_exit" in src + + +# --------------------------------------------------------------------------- +# S5 - worktree_prepare.sh passes 44 / 45 through with its own message +# --------------------------------------------------------------------------- + +def _git(repo, *args): + subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=t", *args], + cwd=str(repo), check=True, capture_output=True, text=True) + + +@pytest.mark.parametrize("rc,word", [(44, "error"), (45, "timeout")]) +def test_s5_worktree_prepare_passes_create_exit_through(mam_sandbox, rc, word): + main = mam_sandbox + _git(main, "init", "-q") + (main / "README").write_text("x\n") + _git(main, "add", "README") + _git(main, "commit", "-q", "-m", "init") + stub = main / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + stub.write_text(f'#!/usr/bin/env bash\necho "stub create: $*" >&2\nexit {rc}\n') + prep = main / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "worktree_prepare.sh" + wt = main.parent / f"{main.name}-wt{rc}" + res = subprocess.run(["bash", str(prep), "--path", str(wt), "--branch", f"b-{rc}", + "--creator-agent", "claude", "--onboard"], + capture_output=True, text=True, cwd=str(main), timeout=300) + try: + assert res.returncode == rc, res.stdout + res.stderr + assert f"onboarding {word} for 'creator-claude-b-{rc}' (create_session.sh rc={rc}); session and worktree kept" in res.stderr, res.stderr + assert "worktree_remove.sh" not in res.stderr + assert "--onboard" in res.stderr and "stub create" in res.stderr + assert (wt / "README").exists(), "the worktree is kept" + finally: + subprocess.run(["git", "worktree", "remove", "--force", str(wt)], cwd=str(main), capture_output=True)