diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index e98976e..4951ec4 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -3247,3 +3247,55 @@ mam_control_plane_prefix() { [ -n "${1:-}" ] || return 0 PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1" } + +# --------------------------------------------------------------------------- +# Issue #72 (decision 8): the update admission gate. +# +# The update skill publishes .mam/update-in-progress (pid + lstart, loop_lock.sh +# rules) while it holds .mam/update-admission.lock exclusively, then runs its +# pre-check. Every entry that starts something the pre-check must see - create, +# resume, run_loop.sh, delegate submit - or writes the registry - stop, +# orc-onboard - takes that lock shared on fd 7, refuses while the marker is +# `live` or `unknown`, and keeps fd 7 until its record is written +# (mam_update_admit_release). An update therefore either waits for that record +# and sees it, or publishes first and the entry refuses. The lock is a BSD +# flock taken by lib_py.pane_lock on the open fd: it ends when every process +# holding fd 7 has ended, however that ends, so nothing long-lived may inherit +# fd 7 (`7>&-` on such a child, as pane_lock's fd 9). The wait is bounded by +# MAM_UPDATE_ADMISSION_WAIT_S (default 120). +# +# mam_update_admit [workspace]: rc 0 admitted (fd 7 held), 1 refused (stderr). +mam_update_admit() { + local root="${1:-$WORKSPACE_ROOT}" + local lock="$root/.mam/update-admission.lock" marker="$root/.mam/update-in-progress" st + if ! { mkdir -p "$root/.mam" && exec 7>>"$lock"; }; then + echo "ERROR: cannot open the update admission lock $lock" >&2 + return 1 + fi + case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd 7 --shared --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in + locked) ;; + timeout) + exec 7>&- + echo "ERROR: an update of $root holds its admission lock (MAM_UPDATE_ADMISSION_WAIT_S passed); try again when it has finished" >&2 + return 1 ;; + *) + exec 7>&- + echo "ERROR: the update admission lock $lock could not be decided (lib_py.pane_lock)" >&2 + return 1 ;; + esac + [ -f "$marker" ] || return 0 + declare -F _mam_lock_holder_state >/dev/null || source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh" + st="$(_mam_lock_holder_state "$marker")" + case "$st" in + stale) + echo "NOTE: stale update marker $marker (its update is gone); ignored" >&2 + return 0 ;; + esac + exec 7>&- + echo "ERROR: an update of $root is in progress (marker $marker, holder $st); try again when it has finished" >&2 + return 1 +} + +mam_update_admit_release() { + exec 7>&- +} diff --git a/.agents/skills/lib_py/pane_lock.py b/.agents/skills/lib_py/pane_lock.py index bfc2cc9..62a4824 100644 --- a/.agents/skills/lib_py/pane_lock.py +++ b/.agents/skills/lib_py/pane_lock.py @@ -18,6 +18,9 @@ one lock per server from the choice to the claim. pane_lock.py acquire --fd N flock(LOCK_EX) on the inherited fd N; prints `locked`, or `timeout` once MAM_PANE_CLAIM_WAIT_S (default 120, minimum 1) has passed + [--shared] [--wait-env VAR] Issue #72: LOCK_SH instead, and the wait read + from VAR (same default and minimum) - the + update admission lock (lib.sh mam_update_admit) The lock is a BSD flock, which belongs to the open file on fd 9: it outlives this helper and ends when the last process holding that fd ends, however it @@ -63,11 +66,11 @@ def lock_path(session: Optional[str], env: Mapping[str, str]) -> str: return os.path.join(home, ".local", "state", "mam", "pane-lock", session + ".lock") -def wait_s(env: Mapping[str, str]) -> int: - return ints.positive_int(env.get("MAM_PANE_CLAIM_WAIT_S"), 1) or DEFAULT_WAIT_S +def wait_s(env: Mapping[str, str], var: str = "MAM_PANE_CLAIM_WAIT_S") -> int: + return ints.positive_int(env.get(var), 1) or DEFAULT_WAIT_S -def acquire(fd: int, wait: float) -> str: +def acquire(fd: int, wait: float, shared: bool = False) -> str: """`locked`, or `timeout` when the lock was not free within `wait` s. The deadline is on the monotonic clock; a step of the wall clock does not stretch or cut it. Only contention is waited out: any other error (a bad @@ -75,7 +78,7 @@ def acquire(fd: int, wait: float) -> str: deadline = time.monotonic() + wait while True: try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB) return "locked" except BlockingIOError: if time.monotonic() >= deadline: @@ -90,11 +93,13 @@ def main(argv: Optional[List[str]] = None) -> int: s.add_argument("--session", default="") s = sub.add_parser("acquire") s.add_argument("--fd", type=int, required=True) + s.add_argument("--shared", action="store_true") + s.add_argument("--wait-env", default="MAM_PANE_CLAIM_WAIT_S") a = p.parse_args(argv) if a.cmd == "path": print(lock_path(a.session, os.environ)) else: - print(acquire(a.fd, wait_s(os.environ))) + print(acquire(a.fd, wait_s(os.environ, a.wait_env), a.shared)) return 0 diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index fecf5cc..f859216 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -181,7 +181,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace | Exit | Meaning | |---|---| | 0 | Created; or `--dry-run` | -| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) | +| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600), or an update of the workspace is in progress (`multi-agent-mux-update`). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) | | 2 | Bad arguments; control-plane binding refused for a worktree; whitespace in a control-plane path that must be forwarded | | 3 | A herdr session of that name already exists (`multi-agent-mux-resume`, or `multi-agent-mux-stop` first) | | 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. A row the monitor registered for this very pane while it was being created is replaced instead | 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 18cfa3b..5899963 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -260,7 +260,7 @@ spawn() { SESSION_UUID="" CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")" [ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2 - nohup "$WRAPPER" >/dev/null 2>&1 & + nohup "$WRAPPER" >/dev/null 2>&1 7>&- & disown else HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" @@ -285,6 +285,9 @@ if [ "$DRY_RUN" = "1" ]; then fi # Issue #63: a same-cwd session of this post-discovered agent records its id first (lib.sh mam_id_order_wait). mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" || exit 1 +# Issue #72: no new session while an update runs; held until the row is written +# (lib.sh mam_update_admit). +mam_update_admit || exit 1 HERDR_EPOCH=$(date +%s) NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') spawn @@ -638,6 +641,7 @@ snap['cwd'] = os.environ['PANE_CWD'] print(f"appended: {name}", flush=True) PYEOF ROW_APPENDED=1 +mam_update_admit_release echo echo "=== created ===" 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 e77146a..6223140 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 @@ -301,6 +301,10 @@ cmd_submit() { local reg_dry=() if [[ "$DRY_RUN" == "1" ]]; then reg_dry=(--dry-run); fi + # Issue #72: no job is registered while an update of the target's workspace + # runs; held until the record is written (lib.sh mam_update_admit). A + # dry-run record is never dispatched and is not counted by the update. + if [[ "$DRY_RUN" != "1" ]]; then mam_update_admit "$WORKSPACE_ROOT" || exit 1; fi # 1) register job (prints the new job id) JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \ "${reg_prompt[@]}" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \ @@ -310,6 +314,7 @@ cmd_submit() { --job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \ --max-iterations "$MAX_ITERATIONS" ${reg_parent[@]+"${reg_parent[@]}"} ${reg_dry[@]+"${reg_dry[@]}"})" echo "registered job: $JOB_ID" + [[ "$DRY_RUN" == "1" ]] || mam_update_admit_release # 1-1) Provision job directory and write direct brief.md (MAM Job Restructuring) local job_dir="$REGISTRY_DIR/$JOB_ID" 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 7c481b3..d8369ce 100644 --- a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh @@ -244,6 +244,9 @@ log_error() { echo -e "\033[1;31m[✗]\033[0m $1" } +# Issue #72: no loop starts while an update runs; held until the loop marker +# is published (lib.sh mam_update_admit), released before the loop body runs. +mam_update_admit "$MAM_REAL_ROOT" || exit 1 _mam_acquire_rc=0 mam_acquire_loop_lock "$MAM_LOOP_MARKER" || _mam_acquire_rc=$? case "$_mam_acquire_rc" in @@ -262,6 +265,7 @@ case "$_mam_acquire_rc" in ;; esac trap _mam_release_guard EXIT INT TERM HUP +mam_update_admit_release rm -f "$MAM_REAL_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job".*.tmp 2>/dev/null || true # --all-reviewer silently takes precedence over an explicit --reviewer list; diff --git a/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh b/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh index 9235ea1..ccd447d 100755 --- a/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh +++ b/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh @@ -257,6 +257,11 @@ if [ "$DRY_RUN" = true ]; then exit 0 fi +# Issue #72 (U2): no registry write while an update runs - a rollback would +# restore the registry without it. Held until the write is done (lib.sh +# mam_update_admit). +mam_update_admit || exit 1 + # Issue #13: the orchestrator has no herdr_sessions row, so stop_session.sh # --purge-conversation can never reach its conversation. Purge FIRST, through # the adapter (`lib_py.agents purge`): a refusal because the conversation is @@ -304,6 +309,7 @@ elif op == "remove": uuids = [u for u in uuids if u != target] d["orchestrator_uuids"] = uuids PYEOF +mam_update_admit_release case "$MODE" in add) verb="added" ;; diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 634345d..63f7974 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -75,11 +75,11 @@ bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace | Exit | Meaning | |---|---| | 0 | Resumed; or already running, so only the row was updated (no attach); or `--dry-run` | -| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), or agent binary missing/not executable. **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) | +| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), agent binary missing/not executable, or an update of the workspace is in progress (`multi-agent-mux-update`). **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) | | 2 | From the checks before spawn, nothing was changed: bad arguments, `--workspace` differs from the original cwd or it no longer exists, control-plane binding refused, whitespace in the control-plane path. **The YAML update after spawn also exits 2** (bad arguments) | | other | A later step failed (herdr spawn, start-up dialogs, YAML update) and its code is passed through. resume does not roll back | -A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-243). +A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-259). ## Original cwd only (Issue #21 phase 2) diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index cd76362..0668256 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -172,12 +172,15 @@ if [ "$HERDR_ALIVE" = "1" ]; then echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate" exit 0 fi + # Issue #72: no registry write while an update runs (lib.sh mam_update_admit). + mam_update_admit || exit 1 echo "herdr '$SESSION_NAME' already running." # Just update YAML to make sure it's set to running CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ --herdr-session "$HERDR_SESSION_NAME" --already-running \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} + mam_update_admit_release _ensure_monitor_daemon exit 0 fi @@ -251,6 +254,10 @@ if [ "${DRY_RUN:-0}" = "1" ]; then exit 0 fi +# Issue #72: no new session while an update runs; held until the row is written +# (lib.sh mam_update_admit). +mam_update_admit || exit 1 + # Issue #63: wait if another same-cwd session of this post-discovered agent # is still being created or has not recorded its id yet (lib.sh mam_id_order_wait). mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" "$UUID" || exit 1 @@ -287,6 +294,7 @@ CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_r --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ ${DROP_FLAG:+$DROP_FLAG} \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} +mam_update_admit_release _ensure_monitor_daemon echo "Successfully resumed $SESSION_NAME ($AGENT)" diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index cd89e89..eb8db61 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -58,7 +58,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \ | Exit | Meaning | |---|---| | 0 | Stopped; or already `stopped` (no-op, except with `--purge-conversation`, which still purges) | -| 1 | From the checks before the stop, nothing was changed: YAML not found, its row could not be read, or the session is neither registered nor on the herdr server. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) | +| 1 | From the checks before the stop, nothing was changed: YAML not found, its row could not be read, the session is neither registered nor on the herdr server, or an update of the workspace is in progress (`multi-agent-mux-update`). **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) | | 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable | | 3 | `--purge-conversation` without `--yes` | | 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down | diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 50067a5..edeaed6 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -100,6 +100,11 @@ fi [ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; } [ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; } +# Issue #72 (U2): no registry write while an update runs - a rollback would +# restore the registry without it. Held until the row is written (lib.sh +# mam_update_admit). +mam_update_admit || exit 1 + # Implement the purging- file lock mechanism if [ "$PURGE" = "1" ]; then touch "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME" @@ -453,6 +458,7 @@ if purge: else: print(f"updated: {name} status={target['status']}", flush=True) PYEOF +mam_update_admit_release delegate_publish_event "$DELEGATE_JOB_ID" cancelled "session stopped by operator before job completion" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" diff --git a/tests/data/skill_code_blocks.json b/tests/data/skill_code_blocks.json index c04ea77..2e321dd 100644 --- a/tests/data/skill_code_blocks.json +++ b/tests/data/skill_code_blocks.json @@ -11,7 +11,7 @@ "kind": "example", "evidence": [ "multi-agent-mux-create/scripts/create_session.sh:99", - "multi-agent-mux-create/scripts/create_session.sh:356" + "multi-agent-mux-create/scripts/create_session.sh:359" ], "mismatch": null, "note": "HERDR_SESSION_NAME environment example" @@ -67,7 +67,7 @@ "class": "c", "kind": "example", "evidence": [ - "multi-agent-mux-create/scripts/create_session.sh:514-541" + "multi-agent-mux-create/scripts/create_session.sh:517-544" ], "mismatch": null, "note": "Output format: the row create_session.sh writes" @@ -122,7 +122,7 @@ "class": "c", "kind": "invocation", "evidence": [ - "multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1046-1057" + "multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1051-1062" ], "mismatch": null, "note": "CLI reference; subcommands submit/status/list/verify/wait/logs match main()" @@ -433,7 +433,7 @@ "class": "c", "kind": "diagram", "evidence": [ - "multi-agent-mux-stop/scripts/stop_session.sh:245-259" + "multi-agent-mux-stop/scripts/stop_session.sh:250-264" ], "mismatch": null, "note": "state machine" @@ -477,10 +477,10 @@ "multi-agent-mux-resume/scripts/resume_session.sh:111-130", "multi-agent-mux-resume/scripts/resume_session.sh:133-144", "multi-agent-mux-resume/scripts/resume_session.sh:147-170", - "multi-agent-mux-resume/scripts/resume_session.sh:185-210", - "multi-agent-mux-resume/scripts/resume_session.sh:258", - "multi-agent-mux-resume/scripts/resume_session.sh:262", - "multi-agent-mux-resume/scripts/resume_session.sh:268-271", + "multi-agent-mux-resume/scripts/resume_session.sh:188-213", + "multi-agent-mux-resume/scripts/resume_session.sh:265", + "multi-agent-mux-resume/scripts/resume_session.sh:269", + "multi-agent-mux-resume/scripts/resume_session.sh:275-278", "lib_py/agents/adapters/claude.py:109" ], "mismatch": [ @@ -504,8 +504,8 @@ "class": "a", "evidence": [ "multi-agent-mux-stop/scripts/stop_session.sh:101", - "multi-agent-mux-stop/scripts/stop_session.sh:123-193", - "multi-agent-mux-stop/scripts/stop_session.sh:245-249" + "multi-agent-mux-stop/scripts/stop_session.sh:128-198", + "multi-agent-mux-stop/scripts/stop_session.sh:250-254" ], "mismatch": [ "T1: the doc offered --force-no-yaml, which no script accepts" @@ -547,8 +547,8 @@ "multi-agent-mux-create/scripts/create_session.sh:166-168", "multi-agent-mux-create/scripts/create_session.sh:207", "multi-agent-mux-create/scripts/create_session.sh:259-274", - "multi-agent-mux-create/scripts/create_session.sh:361-398", - "multi-agent-mux-create/scripts/create_session.sh:410-412" + "multi-agent-mux-create/scripts/create_session.sh:364-401", + "multi-agent-mux-create/scripts/create_session.sh:413-415" ], "mismatch": [ "C1: an existing session is exit 1 in the doc, exit 3 in the script", @@ -568,7 +568,7 @@ "base_sha": "f71be0f26fb8", "class": "a", "evidence": [ - "multi-agent-mux-create/scripts/create_session.sh:486-604" + "multi-agent-mux-create/scripts/create_session.sh:489-607" ], "mismatch": [], "replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace --agent --role ", @@ -601,7 +601,7 @@ "other" ], "passthrough": { - "1": "multi-agent-mux-stop/scripts/stop_session.sh:384" + "1": "multi-agent-mux-stop/scripts/stop_session.sh:389" } }, "multi-agent-mux-create/scripts/create_session.sh": { @@ -619,7 +619,7 @@ "other" ], "passthrough": { - "4": "multi-agent-mux-create/scripts/create_session.sh:546" + "4": "multi-agent-mux-create/scripts/create_session.sh:549" } } }, diff --git a/tests/test_issue72_admission.py b/tests/test_issue72_admission.py new file mode 100644 index 0000000..7df3e44 --- /dev/null +++ b/tests/test_issue72_admission.py @@ -0,0 +1,196 @@ +"""Issue #72 (decision 8, U2): while an update runs, the entries that start +something or write the registry refuse - create, resume, run_loop.sh, delegate +submit, stop (plain and --purge-conversation) and orc-onboard - and change +nothing. A marker its update left behind (`stale`) refuses nothing. + +The marker is .mam/update-in-progress as the update skill publishes it (pid + +lstart, loop_lock.sh); `live` here is a sleeping helper process's. +""" +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +from pathlib import Path + +import pytest +import yaml + +UUID = "11111111-2222-3333-4444-555555555555" +ORC = "99999999-8888-7777-6666-555555555555" +ORC_OLD = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +@pytest.fixture +def marker(mam_sandbox): + procs = [] + + def make(live: bool): + p = mam_sandbox / ".mam" / "update-in-progress" + if live: + proc = subprocess.Popen(["sleep", "120"], start_new_session=True) + procs.append(proc) + ls = " ".join(subprocess.run(["ps", "-p", str(proc.pid), "-o", "lstart="], + capture_output=True, text=True).stdout.split()) + p.write_text("pid=%d\nlstart=%s\nstarted=x\n" % (proc.pid, ls)) + else: + dead = subprocess.Popen(["true"]) + dead.wait() + p.write_text("pid=%d\nlstart=Thu Jan 1 00:00:00 1970\nstarted=x\n" % dead.pid) + return p + + yield make + for proc in procs: + proc.kill() + proc.wait() + + +def _seed(sandbox, rows, **top): + lib = sandbox / ".agents" / "skills" / "lib.sh" + mutation = "d['herdr_sessions'] = %r\n" % (rows,) + "".join("d[%r] = %r\n" % kv for kv in top.items()) + res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$AGENT_SESSIONS_YAML"', "_", str(lib)], + input=mutation, capture_output=True, text=True) + assert res.returncode == 0, res.stderr + + +def _registry(sandbox): + out = {} + for f in ("agent-sessions.yaml", "agent-sessions.db"): + p = sandbox / ".mam" / f + out[f] = hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None + return out + + +def _script(sandbox, rel): + return str(sandbox / ".agents" / "skills" / rel) + + +def _run(sandbox, argv, env=None, timeout=120): + return subprocess.run(argv, capture_output=True, text=True, cwd=str(sandbox), timeout=timeout, + env=dict(os.environ, **(env or {})), stdin=subprocess.DEVNULL) + + +def _refused(res, sandbox): + assert res.returncode != 0, res.stdout + res.stderr + assert "an update of %s is in progress" % sandbox in res.stderr, res.stdout + res.stderr + + +def _row(name, status="running", agent="claude"): + r = {"name": name, "status": status, "role": "creator", "herdr_session": "default", "herdr_server": "default", + "pane": {"cwd": "", "cmd": agent}, "agent": agent} + if agent == "claude": + r["claude_session_id_own"] = UUID + return r + + +def _agent(cwd, pane): + return {"agent": "claude", "status": "running", "cwd": str(cwd), "workspace_id": "w1", + "pid": 9999, "pane_id": pane, "command": "claude", "buffer": "Ready"} + + +def _set_mock(state_file, **kw): + st = json.loads(Path(state_file).read_text()) + st.update(kw) + Path(state_file).write_text(json.dumps(st)) + + +def test_create_refuses_while_an_update_runs(mam_sandbox, mock_herdr, mock_agents, marker): + marker(True) + before = _registry(mam_sandbox) + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-create/scripts/create_session.sh"), + "--workspace", str(mam_sandbox), "--agent", "claude", "--role", "creator", + "--session", "gated-create", "--no-onboard"], timeout=300) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + assert json.loads(Path(mock_herdr).read_text())["agents"] == {}, "nothing was spawned" + + +def test_resume_refuses_on_the_spawn_and_the_alive_path(mam_sandbox, mock_herdr, mock_agents, marker): + _seed(mam_sandbox, [dict(_row("gated-agy", status="stopped", agent="agy"), pane={"cwd": str(mam_sandbox), "cmd": "agy"})]) + marker(True) + before = _registry(mam_sandbox) + script = _script(mam_sandbox, "multi-agent-mux-resume/scripts/resume_session.sh") + res = _run(mam_sandbox, ["bash", script, "--workspace", str(mam_sandbox), "--agent", "agy", + "--session", "gated-agy"], timeout=300) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + assert json.loads(Path(mock_herdr).read_text())["agents"] == {}, "nothing was spawned" + + _set_mock(mock_herdr, agents={"gated-agy": _agent(mam_sandbox, "w1:p9")}) + res = _run(mam_sandbox, ["bash", script, "--workspace", str(mam_sandbox), "--agent", "agy", + "--session", "gated-agy"], timeout=300) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + + +def test_run_loop_refuses_before_its_loop_marker(mam_sandbox, marker): + marker(True) + loop_marker = mam_sandbox / ".mam" / "loop-guard-active" + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-loop/scripts/run_loop.sh"), + "--creator", "c", "--task", "t"], + env={"MAM_LOOP_NO_FREEZE": "1", "MAM_REAL_ROOT": str(mam_sandbox)}) + _refused(res, mam_sandbox) + assert not loop_marker.exists() + + +def test_delegate_submit_registers_no_job(mam_sandbox, mock_herdr, marker): + marker(True) + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"), + "submit", "--agent", "claude-code", "--agent-session", "herdr:anyone", "--prompt", "hi"]) + _refused(res, mam_sandbox) + jobs = mam_sandbox / ".mam" / "jobs" + assert not list(jobs.glob("*.json")) if jobs.exists() else True + + +@pytest.mark.parametrize("purge", [False, True]) +def test_stop_refuses_and_changes_nothing(mam_sandbox, mock_herdr, marker, purge): + _seed(mam_sandbox, [_row("gated-stop")]) + _set_mock(mock_herdr, agents={"gated-stop": _agent(mam_sandbox, "w1:p8")}) + marker(True) + before = _registry(mam_sandbox) + extra = ["--purge-conversation", "--yes"] if purge else [] + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-stop/scripts/stop_session.sh"), + "--session", "gated-stop", "--agent", "claude", *extra]) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + assert "gated-stop" in json.loads(Path(mock_herdr).read_text())["agents"], "the session was not touched" + assert not (mam_sandbox / ".mam" / "purging-gated-stop").exists() + + +def test_orc_onboard_refuses_and_changes_nothing(mam_sandbox, marker): + _seed(mam_sandbox, [], orchestrator_uuids=[ORC_OLD]) + marker(True) + before = _registry(mam_sandbox) + script = _script(mam_sandbox, "multi-agent-mux-orc-onboard/scripts/orc_onboard.sh") + for args in (["--uuid", ORC], ["--remove", ORC_OLD]): + res = _run(mam_sandbox, ["bash", script, *args]) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + + +def test_a_stale_marker_refuses_nothing(mam_sandbox, mock_herdr, marker): + _seed(mam_sandbox, [_row("stale-stop")], orchestrator_uuids=[]) + _set_mock(mock_herdr, agents={"stale-stop": _agent(mam_sandbox, "w1:p7")}) + marker(False) + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-orc-onboard/scripts/orc_onboard.sh"), + "--uuid", ORC]) + assert res.returncode == 0, res.stdout + res.stderr + assert "stale update marker" in res.stderr + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-stop/scripts/stop_session.sh"), + "--session", "stale-stop", "--agent", "claude"]) + assert res.returncode == 0, res.stdout + res.stderr + d = yaml.safe_load((mam_sandbox / ".mam" / "agent-sessions.yaml").read_text()) + assert d["orchestrator_uuids"] == [ORC] + assert {r["name"]: r["status"] for r in d["herdr_sessions"]} == {"stale-stop": "stopped"} + + +def test_the_admission_lock_is_released_after_the_record(mam_sandbox, marker): + """An entry takes the lock shared and drops it once its record is written: + a later exclusive taker (the update) gets it at once.""" + lib = mam_sandbox / ".agents" / "skills" / "lib.sh" + res = subprocess.run(["bash", "-c", 'source "$1" && mam_update_admit && mam_update_admit_release && ' + 'exec 7>>"$WORKSPACE_ROOT/.mam/update-admission.lock" && ' + 'MAM_UPDATE_ADMISSION_WAIT_S=1 python3 -m lib_py.pane_lock acquire --fd 7 --wait-env MAM_UPDATE_ADMISSION_WAIT_S', + "_", str(lib)], capture_output=True, text=True, timeout=60) + assert res.returncode == 0 and res.stdout.strip() == "locked", res.stdout + res.stderr