feat(create,resume): same-cwd ordering of post-discovered agents (Issue #63)

This commit is contained in:
Godopu committed 2026-10-21 16:28:34 +00:00
1 parent fa93cc5622
commit e594d2b650
5 files changed
+444 -2

No files matched your search

+174
View File
@@ -612,4 +612,178 @@ def test_rev2_claude_unpinned_sibling_row_skips_creator_claimed_id(mam_sandbox,
assert f"-r {UB}" in dry_after.stdout
# --------------------------------------------------------------------------- Done Criterion 6 (id_order & create/resume wait)
def test_c6_create_waits_or_refuses_while_same_cwd_post_discovered_row_has_no_id(
mam_sandbox, mock_herdr, mock_agents
):
"""Done Criterion 6 (a) & (b): A is running without an id and has a live pane pid.
(a) With MAM_ID_ORDER_WAIT_S=0, create B exits 1 without spawning or adding a row
and names A on stderr.
(b) With MAM_ID_ORDER_WAIT_S=30, once A records its id in a background thread,
create B proceeds (rc 0) and records herdr_session_epoch >= the pin time."""
import threading
ws = mam_sandbox / "c6-ws"
ws.mkdir(parents=True, exist_ok=True)
epoch_a = int(time.time()) - 100
name_a, name_b = "c6-ws-creator-agy", "c6-ws-reviewer-agy"
create_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
holder = subprocess.Popen(["sleep", "120"])
try:
_write_rows(mam_sandbox, [_row(name_a, "agy", ws, epoch_a, pid=holder.pid)])
_mock_herdr_agent(mock_herdr, name_a, ws, holder.pid, epoch_a, pane_id="w1:p_a")
# (a) Wait cap 0 -> immediate refusal
env0 = dict(os.environ, MAM_ID_ORDER_WAIT_S="0")
res0 = subprocess.run(
["bash", str(create_sh), "--workspace", str(ws), "--agent", "agy",
"--role", "reviewer", "--session", name_b, "--no-onboard"],
capture_output=True, text=True, cwd=str(mam_sandbox), env=env0, timeout=120,
)
assert res0.returncode == 1, res0.stdout + res0.stderr
assert name_a in res0.stderr
assert name_b not in _read_rows(mam_sandbox)
herdr_state = json.loads(Path(mock_herdr).read_text())
assert name_b not in herdr_state.get("agents", {})
# Also check resume --dry-run does not wait even with MAM_ID_ORDER_WAIT_S=0
_write_rows(mam_sandbox, [
_row(name_a, "agy", ws, epoch_a, pid=holder.pid),
_row(name_b, "agy", ws, epoch_a, status="stopped", agy_conversation_id_own=UB),
])
_agy_full_artifacts(mam_sandbox, UB)
dry_res = subprocess.run(
["bash", str(mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"),
"--workspace", str(ws), "--agent", "agy", "--session", name_b, "--dry-run"],
capture_output=True, text=True, cwd=str(mam_sandbox), env=env0, timeout=60,
)
assert dry_res.returncode == 0, dry_res.stdout + dry_res.stderr
# Reset to only A running without id for (b)
_write_rows(mam_sandbox, [_row(name_a, "agy", ws, epoch_a, pid=holder.pid)])
pinned_at = []
def _pin_a_later():
time.sleep(1.5)
pinned_at.append(int(time.time()))
_write_rows(mam_sandbox, [
_row(name_a, "agy", ws, epoch_a, pid=holder.pid, agy_conversation_id_own=UA,
session_id_source="discovered", session_id_verified=True),
])
t = threading.Thread(target=_pin_a_later)
t.start()
try:
env30 = dict(os.environ, MAM_ID_ORDER_WAIT_S="30")
res_ok = subprocess.run(
["bash", str(create_sh), "--workspace", str(ws), "--agent", "agy",
"--role", "reviewer", "--session", name_b, "--no-onboard"],
capture_output=True, text=True, cwd=str(mam_sandbox), env=env30, timeout=120,
)
finally:
t.join(timeout=10)
assert res_ok.returncode == 0, res_ok.stdout + res_ok.stderr
assert "id-order: waiting for" in res_ok.stderr and name_a in res_ok.stderr
rows = _read_rows(mam_sandbox)
assert name_b in rows and rows[name_b]["status"] == "running"
assert pinned_at and rows[name_b]["herdr_session_epoch"] >= pinned_at[0]
finally:
holder.kill()
holder.wait()
def test_c6_id_order_markers_non_blocking_cases_and_uuid_recheck(tmp_path, monkeypatch):
"""Done Criterion 6 (c), (d), and Criterion 7 uuid recheck during wait + knob parsing."""
from lib_py.agents import id_order
home = tmp_path / "home"
ws = tmp_path / "ws"
ws_other = tmp_path / "ws-other"
ws.mkdir(parents=True)
ws_other.mkdir(parents=True)
yaml_path = tmp_path / "agent-sessions.yaml"
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": []}))
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
# Knob parsing rules
assert id_order.wait_s({}) == 600
assert id_order.wait_s({"MAM_ID_ORDER_WAIT_S": "0"}) == 0
assert id_order.wait_s({"MAM_ID_ORDER_WAIT_S": " 05 "}) == 5
for bad in ("abc", "-1", "9" * 10, ""):
assert id_order.wait_s({"MAM_ID_ORDER_WAIT_S": bad}) == 600, bad
# (c) Marker-only "being created": blocks while owner_pid is alive, passes once dead
dpath = id_order.order_dir(str(ws), "agy", home=str(home))
proc = subprocess.Popen(["sleep", "120"])
try:
id_order.write_marker(dpath, "creating-a-agy", proc.pid, str(ws), "agy")
assert id_order.wait(str(ws), "agy", "creating-b-agy", os.getpid(),
yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 1
finally:
proc.kill()
proc.wait()
# Dead owner_pid marker is pruned and wait succeeds
assert id_order.wait(str(ws), "agy", "creating-b-agy", os.getpid(),
yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 0
# Clean up creating-b-agy's own marker for next sub-checks
for f in Path(dpath).glob("*.json"):
f.unlink()
# (d) Non-blocking cases: claude, different folder, different agent, dead pane pid, pinned row
dead_proc = subprocess.Popen(["true"])
dead_proc.wait()
live_proc = subprocess.Popen(["sleep", "120"])
try:
state_nonblock = {
"herdr_sessions": [
_row("other-cwd-agy", "agy", ws_other, 100, pid=live_proc.pid),
_row("same-cwd-codex", "codex", ws, 100, pid=live_proc.pid),
_row("dead-pane-agy", "agy", ws, 100, pid=dead_proc.pid),
_row("pinned-agy", "agy", ws, 100, pid=live_proc.pid, agy_conversation_id_own=UA),
]
}
yaml_path.write_text(yaml.safe_dump(state_nonblock))
assert id_order.wait(str(ws), "claude", "new-claude", os.getpid(),
yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 0
assert id_order.wait(str(ws), "agy", "new-agy", os.getpid(),
yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 0
# Criterion 7: wait --uuid refuses when another row already claimed that uuid
assert id_order.wait(str(ws), "agy", "resuming-agy", os.getpid(), uuid=UA,
yaml_path=str(yaml_path), home=str(home), wait_s_val=0) == 1
finally:
live_proc.kill()
live_proc.wait()
def test_portability_python39_ast_and_bash_syntax():
"""Static guard: new/updated Python modules parse under Python 3.9 and shell scripts pass bash -n."""
import ast
for rel in (
"lib_py/workspace_uuid.py",
"lib_py/agents/session_ids.py",
"lib_py/agents/id_order.py",
):
src = (SKILLS / rel).read_text(encoding="utf-8")
ast.parse(src, filename=rel, feature_version=(3, 9))
for rel in (
"lib.sh",
"multi-agent-mux-stop/scripts/stop_session.sh",
"multi-agent-mux-resume/scripts/resolve_session_id.sh",
"multi-agent-mux-resume/scripts/resume_session.sh",
"multi-agent-mux-resume/scripts/update_yaml_resumed.sh",
"multi-agent-mux-create/scripts/create_session.sh",
):
res = subprocess.run(["bash", "-n", str(SKILLS / rel)], capture_output=True, text=True, timeout=30)
assert res.returncode == 0, f"{rel}: {res.stderr}"