Reproduction first (herdr 0.9.3, a real claude, one-off server, job 51ccd800): after a job that left a background subagent running (`← 1 agent` on screen for 5 min), herdr reported `working` only while the job ran and `idle` ~10 s after `completed`; a second submit with a 20 s idle wait was injected, not cancelled. The 2026-10-04 observation (0.9.1, 10+ min of `working`) does not reproduce on 0.9.3, so the herdr idle wait stays and the registry wait runs before it (plan §4.3 (나)). - lib_py/delegation/wrapper.py: `open-jobs` (diagnostic) and `wait-open-jobs`. An open job of the session is live when the #18 contract says so: `running` with an event in events.log within idle_timeout_sec; `pending` with a dispatch signal (dispatch_wait_at, else created_at) within ack_timeout_sec. A dead record is reported (`stale open job <id> ignored (...)`) and never waited for. The waiter signals every 10 s by merging dispatch_wait_at into its own pending record through the registry's transition boundary (status untouched, no event), so later waiters see it alive; a waiter whose wrapper is gone stops and its signal goes stale (Creator's challenge, job bf8863e1). Release is FIFO by (created_at, job_id): `clear` when no live running job remains and this job is the earliest live pending one; `busy <ids>` at the bound min(MAM_DISPATCH_WAIT_S, remaining wall clock). - the wrapper: _registry_wait after the preflight in both dispatch paths; `busy` closes the job as cancelled/session_busy (phase registry_wait), exit 5 - the existing contract. - tests/test_issue50_dispatch.py: the open-job table (status x age x signal x spelling), FIFO and the tie, the signal as a registry merge, the bound, a dead wrapper, finalize-end's new phase, both call sites. - tests/test_issue50_dispatch_herdr.py: a real herdr 0.9.3 server per test (short mkdtemp HOME, fake claude the server reports idle, the real publish path over the suite's broker): C1 wait-then-run, C2 the bound, C3 a dead running record, C4 the loop runner's first job behind an onboarding, C5 a session without a row, C7 two waiters in FIFO order with no cancellation, C8 a killed waiter skipped once stale; and three concurrent `create --onboard` / `worktree_prepare.sh --onboard` in one server (one server process survives the shims' concurrent bootstrap, so no bootstrap lock is needed). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
573 lines
27 KiB
Python
573 lines
27 KiB
Python
"""Issue #50, against a real herdr 0.9.3 server: the registry decides whether a
|
||
session is free (C), and three `create --onboard` at once do not contend (A).
|
||
|
||
Each test gets a one-off herdr server (a session named t50-<uuid>) under a
|
||
short mkdtemp HOME - herdr's sockets, logs and server config live there, the
|
||
user's herdr config is never touched, and a scratch HOME under tmp_path would
|
||
exceed the unix socket path limit. The pane runs a fake claude (a claude-like
|
||
welcome, then it echoes its input), which herdr 0.9.3 reports as `idle`; the
|
||
fake never shows `working`, so `agent prompt --wait` times out and the
|
||
injection is judged by the job's own `started` (Issue #18), which these tests
|
||
publish as the agent would, through the real publish_event.py and the suite's
|
||
local broker. The box is torn down after every test.
|
||
|
||
Measured on 2026-10-10 (job 51ccd800, herdr 0.9.3, a real claude): herdr
|
||
reported `working` only while a job ran and `idle` ~10 s after `completed`,
|
||
background subagent (`← 1 agent`) or not, and a second job was dispatched;
|
||
so the herdr idle wait stays and the registry wait runs before it.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import uuid
|
||
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"
|
||
PUBLISH_PY = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py"
|
||
CREATE_SH = SKILLS / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||
if str(SKILLS) not in sys.path:
|
||
sys.path.insert(0, str(SKILLS))
|
||
|
||
from lib_py.delegation import registry # noqa: E402
|
||
|
||
HERDR = shutil.which("herdr", path="/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:" + os.environ.get("PATH", ""))
|
||
pytestmark = pytest.mark.skipif(not HERDR, reason="herdr is not installed")
|
||
|
||
SESSION = "t50-creator-claude"
|
||
FAKE_CLAUDE = r'''#!/usr/bin/env bash
|
||
# fake claude for the real-herdr tests: a claude-like welcome, then it echoes its input
|
||
if [ "${1:-}" = "auth" ]; then echo '{"loggedIn": true}'; exit 0; fi
|
||
printf '\n ▐▛███▛█ Claude Code v2.1.0 (fake)\n▝▜██████▀ Sonnet · Claude Max\n\n'
|
||
printf '───────────────────────────────────────────────\n❯ Try "how does <filepath> work?"\n───────────────────────────────────────────────\n ⏵⏵ bypass permissions on (shift+tab to cycle)\n'
|
||
while IFS= read -r line; do printf '\n● got: %s\n❯ \n' "$line"; done
|
||
'''
|
||
|
||
|
||
class Box:
|
||
def __init__(self, home: Path, sess: str):
|
||
self.home, self.sess = home, sess
|
||
self.ctl, self.ws, self.bin = home / "ctl", home / "ws", home / "bin"
|
||
self.jobs = self.ctl / ".mam" / "jobs"
|
||
self.yaml = self.ctl / ".mam" / "agent-sessions.yaml"
|
||
|
||
# -- environment -------------------------------------------------------
|
||
def env(self, **over) -> dict:
|
||
env = {k: v for k, v in os.environ.items()
|
||
if not k.startswith("HERDR_") and k not in ("MAM_JOB_ID", "MAM_VERSION", "CLAUDE_PROJECT_DIR",
|
||
"HOME_DIR", "XDG_CONFIG_HOME")}
|
||
env.update(HOME=str(self.home), PATH=f"{self.bin}:{env.get('PATH', '')}", HERDR_SESSION_NAME=self.sess,
|
||
WORKSPACE_ROOT=str(self.ctl), AGENT_SESSIONS_YAML=str(self.yaml), MAM_JOBS_DIR=str(self.jobs),
|
||
DELEGATE_JOB_LOGS_DIR=str(self.ctl / ".mam" / "delegate_job_logs"),
|
||
MAM_ENV_FILE=str(self.ctl / ".mam.env.absent"), MAM_NO_SUBSCRIBER_DAEMON="1",
|
||
DELEGATE_JOB_PYTHON=sys.executable, AGENT_PYTHON_BIN=sys.executable,
|
||
MAM_ONBOARD_ACK_S="60")
|
||
env.update({k: str(v) for k, v in over.items()})
|
||
return env
|
||
|
||
def sh(self, script: str, timeout=120, **over) -> subprocess.CompletedProcess:
|
||
"""A bash snippet after `source lib.sh` (so `herdr` is the shim)."""
|
||
return subprocess.run(["bash", "-c", f'source "{SKILLS}/lib.sh"\n{script}'], capture_output=True, text=True,
|
||
cwd=str(self.ctl), env=self.env(**over), timeout=timeout)
|
||
|
||
def herdr(self, *args, timeout=30) -> subprocess.CompletedProcess:
|
||
"""The real binary against this box's server (no shim)."""
|
||
env = {"HOME": str(self.home), "PATH": os.environ.get("PATH", "")}
|
||
return subprocess.run([HERDR, "--session", self.sess, *args], capture_output=True, text=True, env=env,
|
||
timeout=timeout)
|
||
|
||
def agent_status(self, name=SESSION) -> str:
|
||
res = self.herdr("agent", "get", name)
|
||
try:
|
||
return json.loads(res.stdout)["result"]["agent"].get("agent_status", "")
|
||
except (ValueError, KeyError):
|
||
return ""
|
||
|
||
def pane_text(self, name=SESSION) -> str:
|
||
res = self.herdr("agent", "read", name, "--source", "recent")
|
||
try:
|
||
d = json.loads(res.stdout)["result"]
|
||
return (d.get("read") or d).get("text", "")
|
||
except (ValueError, KeyError, AttributeError):
|
||
return res.stdout
|
||
|
||
def server_pids(self) -> list:
|
||
out = subprocess.run(["pgrep", "-f", f"herdr --session {self.sess} server"], capture_output=True, text=True).stdout
|
||
return [int(p) for p in out.split()]
|
||
|
||
# -- registry --------------------------------------------------------------
|
||
def register(self, session=f"herdr:{SESSION}", **kw) -> str:
|
||
kw.setdefault("ack_timeout_sec", 60)
|
||
return registry.register_job(prompt="prior work", agent="claude-code", agent_session=session,
|
||
registry_dir=str(self.jobs), **kw)
|
||
|
||
def record(self, jid: str) -> dict:
|
||
return json.loads((self.jobs / f"{jid}.json").read_text())
|
||
|
||
def events(self, jid: str) -> list:
|
||
path = self.jobs / f"{jid}.events.log"
|
||
if not path.exists():
|
||
return []
|
||
return [json.loads(l) for l in path.read_text().splitlines() if l.strip()]
|
||
|
||
def publish(self, jid: str, event: str, detail: str = "") -> None:
|
||
rec = self.record(jid)
|
||
cmd = [sys.executable, str(PUBLISH_PY), "--registry-dir", str(self.jobs), "--job", jid, "--event", event,
|
||
"--expect-dispatch-token", rec["dispatch_token"], "--attempt-id", str(rec.get("attempt_id", 1))]
|
||
if detail:
|
||
cmd += ["--detail", detail]
|
||
res = subprocess.run(cmd, capture_output=True, text=True, env=self.env(), timeout=120)
|
||
assert res.returncode == 0, (event, res.stdout, res.stderr)
|
||
|
||
def job_ids(self, session=f"herdr:{SESSION}") -> set:
|
||
out = set()
|
||
for p in self.jobs.glob("*.json"):
|
||
try:
|
||
if json.loads(p.read_text()).get("agent_session") == session:
|
||
out.add(p.stem)
|
||
except ValueError:
|
||
pass
|
||
return out
|
||
|
||
def wait_new_job(self, known: set, timeout=60) -> str:
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
new = self.job_ids() - known
|
||
if new:
|
||
assert len(new) == 1, new
|
||
return new.pop()
|
||
time.sleep(0.2)
|
||
raise AssertionError("no new job was registered")
|
||
|
||
def wait_field(self, jid: str, key: str, timeout=60) -> str:
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
if self.record(jid).get(key):
|
||
return self.record(jid)[key]
|
||
time.sleep(0.2)
|
||
raise AssertionError(f"{jid} never got {key}")
|
||
|
||
# -- the wrapper ---------------------------------------------------------------
|
||
def submit(self, prompt="work", session=f"herdr:{SESSION}", timeout="300", ack="60", **over) -> subprocess.Popen:
|
||
out = tempfile.TemporaryFile("w+", dir=str(self.home))
|
||
err = tempfile.TemporaryFile("w+", dir=str(self.home))
|
||
proc = subprocess.Popen(["bash", str(WRAPPER), "submit", "--agent", "claude-code", "--agent-session", session,
|
||
"--role", "Worker", "--timeout", timeout, "--ack-timeout", ack,
|
||
"--registry-dir", str(self.jobs), "--prompt", prompt],
|
||
stdout=out, stderr=err, text=True, cwd=str(self.ctl), env=self.env(**over))
|
||
proc._out, proc._err = out, err # type: ignore[attr-defined]
|
||
return proc
|
||
|
||
@staticmethod
|
||
def finish(proc: subprocess.Popen, timeout=240):
|
||
"""The wrapper's (rc, stdout, stderr), after every timer-driven publish
|
||
has returned: the wrapper ends as soon as the subscriber sees
|
||
`completed` on the wire, while publish_event's registry write can
|
||
land a moment later."""
|
||
rc = proc.wait(timeout=timeout)
|
||
for t in list(_TIMERS):
|
||
t.join(timeout=60)
|
||
proc._out.seek(0); proc._err.seek(0) # type: ignore[attr-defined]
|
||
return rc, proc._out.read(), proc._err.read() # type: ignore[attr-defined]
|
||
|
||
|
||
def _row(sess: str, name=SESSION, ws="") -> str:
|
||
return (f"- name: {name}\n status: running\n role: creator\n herdr_session: {sess}\n herdr_server: {sess}\n"
|
||
f" herdr_workspace: t50\n delegate_job_id: null\n pane: {{index: 0, pid: 0, cmd: claude, cwd: {ws}}}\n")
|
||
|
||
|
||
def _make_box(sess: str) -> Box:
|
||
home = Path(tempfile.mkdtemp(prefix="mam50-")) # short: a unix socket path lives under it
|
||
box = Box(home, sess)
|
||
for d in (box.bin, box.ws, box.ctl / ".agents", box.jobs, box.ctl / ".mam" / "delegate_job_logs"):
|
||
d.mkdir(parents=True)
|
||
(box.ctl / ".agents" / "skills").symlink_to(SKILLS)
|
||
(box.bin / "claude").write_text(FAKE_CLAUDE)
|
||
(box.bin / "claude").chmod(0o755)
|
||
box.yaml.write_text("herdr_sessions:\n" + _row(sess, ws=str(box.ws)))
|
||
return box
|
||
|
||
|
||
def _start_pane(box: Box, name=SESSION, cwd=None) -> None:
|
||
res = box.sh(f'herdr new-session -d -s {name} -c "{cwd or box.ws}" "claude --dangerously-skip-permissions"')
|
||
assert res.returncode == 0, res.stdout + res.stderr
|
||
deadline = time.time() + 30
|
||
while time.time() < deadline and box.agent_status(name) != "idle":
|
||
time.sleep(0.5)
|
||
assert box.agent_status(name) == "idle", box.herdr("agent", "list").stdout
|
||
|
||
|
||
def _teardown(box: Box) -> None:
|
||
if os.environ.get("MAM_T50_KEEP_BOX"): # debugging aid: leave the box and the server in place
|
||
print("KEEP_BOX", box.home, box.sess)
|
||
return
|
||
for sub in (("session", "stop", box.sess), ("session", "delete", box.sess)):
|
||
subprocess.run([HERDR, *sub], capture_output=True, text=True, env={"HOME": str(box.home), "PATH": os.environ["PATH"]},
|
||
timeout=30)
|
||
time.sleep(0.5)
|
||
for pid in box.server_pids():
|
||
try:
|
||
os.kill(pid, signal.SIGKILL)
|
||
except OSError:
|
||
pass
|
||
shutil.rmtree(box.home, ignore_errors=True)
|
||
|
||
|
||
@pytest.fixture
|
||
def box(local_mqtt_broker):
|
||
_TIMERS.clear()
|
||
b = _make_box(f"t50-{uuid.uuid4().hex[:8]}")
|
||
try:
|
||
_start_pane(b)
|
||
yield b
|
||
finally:
|
||
_teardown(b)
|
||
|
||
|
||
_TIMERS: list = []
|
||
|
||
|
||
def _after(delay: float, fn, *args):
|
||
t = threading.Timer(delay, fn, args)
|
||
t.daemon = True
|
||
t.start()
|
||
_TIMERS.append(t)
|
||
return t
|
||
|
||
|
||
def _ts(ev: dict) -> str:
|
||
return ev["timestamp"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# C1 - an open prior job ends, then the new one is injected
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_c1_a_job_waits_for_the_sessions_open_job_and_then_runs(box):
|
||
a = box.register()
|
||
box.publish(a, "started") # running, one event on disk
|
||
known = box.job_ids()
|
||
proc = box.submit(MAM_DISPATCH_WAIT_S="20")
|
||
b = box.wait_new_job(known)
|
||
_after(3, box.publish, a, "completed", "prior done")
|
||
_after(5, box.publish, b, "started")
|
||
_after(8, box.publish, b, "completed", "b done")
|
||
rc, out, err = box.finish(proc)
|
||
assert rc == 0, out + err
|
||
assert box.record(b)["status"] == "completed" and box.record(b).get("detail") is None
|
||
assert "cancelled" not in out and "stale open job" not in err
|
||
assert box.record(b).get("dispatch_wait_at"), "B waited (and signalled) while A was open"
|
||
assert _ts(box.events(b)[0]) >= _ts(box.events(a)[-1]), "B started only after A ended"
|
||
assert "got: Your job_id is \"" + b in box.pane_text().replace("\n", "") or b in box.pane_text()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# C2 - the bound: an open job that never ends cancels the new one as session_busy
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_c2_the_wait_bound_closes_the_job_as_session_busy_naming_the_open_one(box):
|
||
a = box.register()
|
||
box.publish(a, "started")
|
||
known = box.job_ids()
|
||
t0 = time.time()
|
||
proc = box.submit(MAM_DISPATCH_WAIT_S="3")
|
||
b = box.wait_new_job(known)
|
||
rc, out, err = box.finish(proc)
|
||
assert rc == 5, out + err
|
||
assert time.time() - t0 < 60
|
||
rec = box.record(b)
|
||
assert rec["status"] == "cancelled" and rec.get("detail") == "session_busy", rec
|
||
assert a in err and "still has open jobs in the registry" in err, err
|
||
assert [e["event"] for e in box.events(b)] == ["cancelled"]
|
||
assert box.record(a)["status"] == "running", "the open job is left alone"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# C3 - a running record nothing has touched for idle_timeout_sec is stale
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_c3_a_dead_running_record_does_not_block_the_session(box):
|
||
a = box.register(idle_timeout_sec=2)
|
||
box.publish(a, "started")
|
||
time.sleep(3) # its only event is now older than idle_timeout_sec
|
||
known = box.job_ids()
|
||
proc = box.submit(MAM_DISPATCH_WAIT_S="30")
|
||
b = box.wait_new_job(known)
|
||
_after(2, box.publish, b, "started")
|
||
_after(4, box.publish, b, "completed", "b done")
|
||
rc, out, err = box.finish(proc)
|
||
assert rc == 0, out + err
|
||
assert f"stale open job {a} ignored (running, no event for 2s)" in err, err
|
||
assert box.record(b)["status"] == "completed"
|
||
assert "dispatch_wait_at" not in box.record(b), "nothing to wait for: no signal written"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# C4 - the loop's first job to a creator whose onboarding is still running
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_c4_the_loops_first_job_waits_for_the_onboarding_to_end(box, monkeypatch):
|
||
onboarding = box.register(ack_timeout_sec=180) # create registers it exactly so: agent_session=herdr:<session>
|
||
box.publish(onboarding, "started")
|
||
for k, v in box.env(MAM_DISPATCH_WAIT_S="30").items():
|
||
monkeypatch.setenv(k, v)
|
||
monkeypatch.delenv("MAM_REAL_ROOT", raising=False)
|
||
from lib_py.loop import runner as lr
|
||
r = lr.LoopRunner(["--creator", SESSION, "--task", "first task"])
|
||
known = box.job_ids()
|
||
result = {}
|
||
|
||
def drive():
|
||
first = box.wait_new_job(known)
|
||
result["first"] = first
|
||
time.sleep(5)
|
||
box.publish(onboarding, "completed", "Onboarding complete; aligned with role creator")
|
||
time.sleep(2)
|
||
box.publish(first, "started")
|
||
time.sleep(2)
|
||
box.publish(first, "completed", "first done")
|
||
|
||
t = threading.Thread(target=drive, daemon=True)
|
||
t.start()
|
||
jid = r.delegate_job(SESSION, "claude-code", "Worker", "the loop's first job") # the runner adds herdr:
|
||
t.join(timeout=60)
|
||
assert jid == result.get("first"), (jid, result)
|
||
assert jid not in r.failed_submit_jobs
|
||
rec = box.record(jid)
|
||
assert rec["status"] == "completed" and rec.get("detail") is None, rec
|
||
assert _ts(box.events(jid)[0]) >= _ts(box.events(onboarding)[-1])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# C5 - a session create never registered: the record's agent_session alone decides
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_c5_a_session_without_a_row_is_judged_by_the_records_alone(local_mqtt_broker):
|
||
# resolve_herdr_session falls back to the workspace slug when the yaml has no
|
||
# row, so the server is named exactly that: mam-<parent>-<work> of the control root
|
||
home = Path(tempfile.mkdtemp(prefix="mam50-"))
|
||
sess = f"mam-{home.name}-ctl".lower().replace("_", "-")
|
||
shutil.rmtree(home)
|
||
_TIMERS.clear()
|
||
b = Box(home, sess)
|
||
for d in (b.bin, b.ws, b.ctl / ".agents", b.jobs, b.ctl / ".mam" / "delegate_job_logs"):
|
||
d.mkdir(parents=True)
|
||
(b.ctl / ".agents" / "skills").symlink_to(SKILLS)
|
||
(b.bin / "claude").write_text(FAKE_CLAUDE)
|
||
(b.bin / "claude").chmod(0o755)
|
||
b.yaml.write_text("herdr_sessions: []\n")
|
||
try:
|
||
_start_pane(b)
|
||
a = b.register()
|
||
b.publish(a, "started")
|
||
known = b.job_ids()
|
||
proc = b.submit(MAM_DISPATCH_WAIT_S="20")
|
||
c = b.wait_new_job(known)
|
||
_after(3, b.publish, a, "completed", "prior done")
|
||
_after(5, b.publish, c, "started")
|
||
_after(7, b.publish, c, "completed", "c done")
|
||
rc, out, err = b.finish(proc)
|
||
assert rc == 0, out + err
|
||
assert b.record(c)["status"] == "completed" and b.record(c).get("dispatch_wait_at")
|
||
assert _ts(b.events(c)[0]) >= _ts(b.events(a)[-1])
|
||
finally:
|
||
_teardown(b)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# C7 - two waiters behind one session: FIFO, one at a time, no cancellation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_c7_two_waiters_run_one_after_the_other_and_neither_is_cancelled(box):
|
||
a = box.register()
|
||
box.publish(a, "started")
|
||
known = box.job_ids()
|
||
proc_b = box.submit(prompt="work B", MAM_DISPATCH_WAIT_S="40")
|
||
b = box.wait_new_job(known)
|
||
time.sleep(1.2)
|
||
proc_c = box.submit(prompt="work C", MAM_DISPATCH_WAIT_S="40")
|
||
c = box.wait_new_job(known | {b})
|
||
assert box.record(b)["created_at"] <= box.record(c)["created_at"]
|
||
box.wait_field(c, "dispatch_wait_at") # both are waiting (C signals too)
|
||
box.wait_field(b, "dispatch_wait_at")
|
||
_after(2, box.publish, a, "completed", "prior done")
|
||
_after(4, box.publish, b, "started")
|
||
_after(7, box.publish, b, "completed", "b done")
|
||
# C is published started only once B has ended - as the real agent, which
|
||
# cannot answer a prompt it has not been given, would
|
||
_after(9, box.publish, c, "started")
|
||
_after(11, box.publish, c, "completed", "c done")
|
||
rc_b, out_b, err_b = box.finish(proc_b)
|
||
rc_c, out_c, err_c = box.finish(proc_c)
|
||
assert (rc_b, rc_c) == (0, 0), out_b + err_b + out_c + err_c
|
||
assert box.record(b)["status"] == box.record(c)["status"] == "completed"
|
||
assert "cancelled" not in out_b + out_c and "stale open job" not in err_b + err_c
|
||
assert _ts(box.events(b)[0]) >= _ts(box.events(a)[-1])
|
||
assert _ts(box.events(c)[0]) >= _ts(box.events(b)[-1]), "C started only after B ended"
|
||
pane = box.pane_text()
|
||
assert pane.index(f'Your job_id is "{b}"') < pane.index(f'Your job_id is "{c}"'), "injected in FIFO order"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# C8 - a waiter whose wrapper died: its signal goes stale and is skipped
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_c8_a_dead_waiter_is_skipped_once_its_signal_is_stale(box):
|
||
a = box.register()
|
||
box.publish(a, "started")
|
||
known = box.job_ids()
|
||
proc_b = box.submit(prompt="work B", ack="5", MAM_DISPATCH_WAIT_S="120")
|
||
b = box.wait_new_job(known)
|
||
box.wait_field(b, "dispatch_wait_at")
|
||
os.kill(proc_b.pid, signal.SIGKILL) # the wrapper is gone; its waiter sees that and stops
|
||
proc_b.wait(timeout=30)
|
||
proc_c = box.submit(prompt="work C", MAM_DISPATCH_WAIT_S="60")
|
||
c = box.wait_new_job(known | {b})
|
||
_after(2, box.publish, a, "completed", "prior done")
|
||
_after(14, box.publish, c, "started") # after B's 5 s ack window has long passed
|
||
_after(16, box.publish, c, "completed", "c done")
|
||
rc, out, err = box.finish(proc_c)
|
||
assert rc == 0, out + err
|
||
assert f"stale open job {b} ignored (pending, no dispatch signal for 5s)" in err, err
|
||
assert box.record(c)["status"] == "completed", (box.record(c), box.events(c), out, err)
|
||
assert box.record(b)["status"] == "pending", "the dead waiter's record is left as it is"
|
||
assert _ts(box.events(c)[0]) >= _ts(box.events(a)[-1])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# A - three `create --onboard` at once in one server
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _drive_onboardings(box: Box, names: list, delays: list, result: dict):
|
||
seen = {}
|
||
deadline = time.time() + 120
|
||
while len(seen) < len(names) and time.time() < deadline:
|
||
for p in box.jobs.glob("*.json"):
|
||
try:
|
||
rec = json.loads(p.read_text())
|
||
except ValueError:
|
||
continue
|
||
sess = rec.get("agent_session", "")
|
||
if sess.startswith("herdr:") and sess[6:] in names and sess[6:] not in seen:
|
||
seen[sess[6:]] = p.stem
|
||
box.publish(p.stem, "started")
|
||
result.setdefault("started_at", {})[sess[6:]] = time.time()
|
||
time.sleep(0.3)
|
||
result["jobs"] = dict(seen)
|
||
for name, delay in zip(names, delays):
|
||
jid = seen.get(name)
|
||
if jid:
|
||
_after(delay, box.publish, jid, "completed", "Onboarding complete; aligned with role creator")
|
||
|
||
|
||
def test_a_three_concurrent_creates_with_onboarding(local_mqtt_broker):
|
||
b = _make_box(f"t50-{uuid.uuid4().hex[:8]}")
|
||
b.yaml.write_text("herdr_sessions: []\n")
|
||
names = [f"t50-{i}-creator-claude" for i in (1, 2, 3)]
|
||
procs = []
|
||
try:
|
||
for i, name in enumerate(names, 1):
|
||
ws = b.home / f"ws{i}"
|
||
ws.mkdir()
|
||
out = tempfile.TemporaryFile("w+", dir=str(b.home))
|
||
err = tempfile.TemporaryFile("w+", dir=str(b.home))
|
||
procs.append((name, subprocess.Popen(
|
||
["bash", str(CREATE_SH), "--workspace", str(ws), "--agent", "claude", "--role", "creator",
|
||
"--session", name, "--onboard", "--herdr-session", b.sess],
|
||
stdout=out, stderr=err, text=True, cwd=str(b.ctl), env=b.env(MAM_ONBOARD_WAIT_S="120")), out, err))
|
||
t0 = time.time()
|
||
result = {}
|
||
drv = threading.Thread(target=_drive_onboardings, args=(b, names, [10, 15, 20], result), daemon=True)
|
||
drv.start()
|
||
outcome = {}
|
||
for name, proc, out, err in procs:
|
||
rc = proc.wait(timeout=240)
|
||
out.seek(0); err.seek(0)
|
||
outcome[name] = (rc, out.read(), err.read())
|
||
elapsed = time.time() - t0
|
||
drv.join(timeout=5)
|
||
assert all(o[0] == 0 for o in outcome.values()), {n: (o[0], o[2][-1500:]) for n, o in outcome.items()}
|
||
# The three start-ups overlap, and the onboarding waits do too: once the
|
||
# last prompt was delivered (its `started`), the whole run ends with the
|
||
# slowest onboarding (20 s) plus create's wrap-up, not with their sum (45 s).
|
||
last_started = max(result["started_at"].values()) - t0
|
||
assert elapsed - last_started <= 20 + 15, (elapsed, last_started, result["started_at"])
|
||
assert max(result["started_at"].values()) - min(result["started_at"].values()) <= 20, \
|
||
("the three creates did not run side by side", result["started_at"])
|
||
for name in names:
|
||
jid = result["jobs"][name]
|
||
assert f"onboarding completed: job {jid} (" in outcome[name][1], outcome[name][1]
|
||
assert b.record(jid)["status"] == "completed"
|
||
rows = re.findall(r"^- name: (\S+)", b.yaml.read_text(), re.M)
|
||
assert sorted(rows) == sorted(names), rows
|
||
assert len(set(result["jobs"].values())) == 3 and all((b.jobs / f"{j}.json").exists() for j in result["jobs"].values())
|
||
assert len(b.server_pids()) == 1, "three shims bootstrapped the server at once; one must remain"
|
||
assert len(json.loads(b.herdr("agent", "list").stdout)["result"]["agents"]) == 3
|
||
finally:
|
||
_teardown(b)
|
||
|
||
|
||
def _git(repo: Path, *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)
|
||
|
||
|
||
def test_a2_three_concurrent_worktree_prepares_with_onboarding(local_mqtt_broker):
|
||
"""worktree_prepare.sh --onboard x3 (different --path, one server): the git
|
||
worktree step, the registry and the server's first start do not contend."""
|
||
prep = SKILLS / "multi-agent-mux-create" / "scripts" / "worktree_prepare.sh"
|
||
b = _make_box(f"t50-{uuid.uuid4().hex[:8]}")
|
||
b.yaml.write_text("herdr_sessions: []\n")
|
||
_git(b.ctl, "init", "-q")
|
||
(b.ctl / "README").write_text("ctl\n")
|
||
_git(b.ctl, "add", "README")
|
||
_git(b.ctl, "commit", "-q", "-m", "init")
|
||
branches = [f"b{i}" for i in (1, 2, 3)]
|
||
names = [f"creator-claude-{br}" for br in branches]
|
||
procs = []
|
||
try:
|
||
for br in branches:
|
||
out = tempfile.TemporaryFile("w+", dir=str(b.home))
|
||
err = tempfile.TemporaryFile("w+", dir=str(b.home))
|
||
procs.append((br, subprocess.Popen(
|
||
["bash", str(prep), "--path", str(b.home / f"wt-{br}"), "--branch", br, "--creator-agent", "claude",
|
||
"--onboard", "--herdr-session", b.sess],
|
||
stdout=out, stderr=err, text=True, cwd=str(b.ctl), env=b.env(MAM_ONBOARD_WAIT_S="120")), out, err))
|
||
t0 = time.time()
|
||
result = {}
|
||
drv = threading.Thread(target=_drive_onboardings, args=(b, names, [10, 15, 20], result), daemon=True)
|
||
drv.start()
|
||
outcome = {}
|
||
for br, proc, out, err in procs:
|
||
rc = proc.wait(timeout=300)
|
||
out.seek(0); err.seek(0)
|
||
outcome[br] = (rc, out.read(), err.read())
|
||
elapsed = time.time() - t0
|
||
drv.join(timeout=5)
|
||
assert all(o[0] == 0 for o in outcome.values()), {n: (o[0], o[2][-1500:]) for n, o in outcome.items()}
|
||
last_started = max(result["started_at"].values()) - t0
|
||
assert elapsed - last_started <= 20 + 15, (elapsed, last_started, result["started_at"])
|
||
for br in branches:
|
||
assert f"WORKTREE={b.home / f'wt-{br}'}" in outcome[br][1] and f"SESSION=creator-claude-{br}" in outcome[br][1]
|
||
assert (b.home / f"wt-{br}" / "README").exists()
|
||
assert b.record(result["jobs"][f"creator-claude-{br}"])["status"] == "completed"
|
||
listed = subprocess.run(["git", "worktree", "list"], cwd=str(b.ctl), capture_output=True, text=True).stdout
|
||
assert all(f"[{br}]" in listed for br in branches), listed
|
||
rows = re.findall(r"^- name: (\S+)", b.yaml.read_text(), re.M)
|
||
assert sorted(rows) == sorted(names), rows
|
||
assert len(b.server_pids()) == 1
|
||
finally:
|
||
_teardown(b)
|