feat(reset): lib_py.reset - targets, spec file, progress, result table (#78)
This commit is contained in:
1 parent
51dfd38f0b
commit
ca2532307f
2 files changed
+1087
No files matched your search
@@ -0,0 +1,378 @@
|
||||
"""Issue #78: multi-agent-mux-reset — unit tests for lib_py.reset and
|
||||
end-to-end tests for reset.sh (populated across Commits 3 and 4).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SKILLS = REPO / ".agents" / "skills"
|
||||
if str(SKILLS) not in sys.path:
|
||||
sys.path.insert(0, str(SKILLS))
|
||||
|
||||
UA = "a7800001-0000-4000-8000-000000000001"
|
||||
UB = "b7800002-0000-4000-8000-000000000002"
|
||||
UC = "c7800003-0000-4000-8000-000000000003"
|
||||
UD = "d7800004-0000-4000-8000-000000000004"
|
||||
UORC = "e7800005-0000-4000-8000-000000000005"
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("own_workspace_root")
|
||||
|
||||
|
||||
def _row(
|
||||
name: str,
|
||||
agent: str,
|
||||
cwd: Path,
|
||||
role: str = "reviewer",
|
||||
status: str = "running",
|
||||
created_at: str = "2026-10-01T00:00:00Z",
|
||||
server: str = "default",
|
||||
label: str = "ws",
|
||||
own_id: str | None = None,
|
||||
pid: int = 4242,
|
||||
) -> dict:
|
||||
from lib_py.agents.registry import own_key
|
||||
|
||||
key = own_key(agent) if agent in ("claude", "agy", "codex", "grok", "hermes", "opencode") else "unknown_id"
|
||||
return {
|
||||
"name": name,
|
||||
"status": status,
|
||||
"role": role,
|
||||
"agent": agent,
|
||||
"herdr_session": server,
|
||||
"herdr_server": server,
|
||||
"herdr_workspace": label,
|
||||
"herdr_session_created_at": created_at,
|
||||
key: own_id,
|
||||
"pane": {"cwd": str(cwd), "pid": pid, "cmd": agent},
|
||||
}
|
||||
|
||||
|
||||
def _run_reset_py(args: list[str], state: dict | None = None, stdin_text: str | None = None, env_extra: dict | None = None):
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = str(SKILLS)
|
||||
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
if state is not None:
|
||||
env["MAM_STATE_JSON"] = json.dumps(state)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "lib_py.reset", *args],
|
||||
env=env,
|
||||
input=stdin_text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Unit tests: lib_py.reset
|
||||
|
||||
def test_reset_py_python39_syntax():
|
||||
src = (SKILLS / "lib_py" / "reset.py").read_text(encoding="utf-8")
|
||||
ast.parse(src, filename="reset.py", feature_version=(3, 9))
|
||||
|
||||
|
||||
def test_select_targets_and_plan_cli(tmp_path: Path):
|
||||
"""§5.2-1: select_targets chooses only status==running rows at realpath(WS),
|
||||
resolving symlinks, listing skipped rows with reasons and orchestrator_uuids,
|
||||
and refusing running control-root rows with missing agent or empty role.
|
||||
"""
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
wt = ws / "wt" / "x"
|
||||
wt.mkdir(parents=True)
|
||||
sym_ws = tmp_path / "sym-ws"
|
||||
sym_ws.symlink_to(ws)
|
||||
|
||||
state = {
|
||||
"orchestrator_uuids": [UORC],
|
||||
"herdr_sessions": [
|
||||
_row("reviewer-claude-01", "claude", ws, role="planner,reviewer", own_id=UA, server="srv1", label="lbl1"),
|
||||
_row("reviewer-grok-01", "grok", sym_ws, role="reviewer", own_id=UB, server="default", label="lbl2"),
|
||||
_row("creator-claude-wt", "claude", wt, role="creator", own_id=UC),
|
||||
_row("stopped-codex-01", "codex", ws, status="stopped", own_id=UD),
|
||||
_row("dead-hermes-01", "hermes", ws, status="terminated", own_id=None),
|
||||
],
|
||||
}
|
||||
|
||||
res = _run_reset_py(["plan", "--workspace", str(ws)], state=state)
|
||||
assert res.returncode == 0, res.stderr
|
||||
plan = json.loads(res.stdout)
|
||||
assert [t["name"] for t in plan["targets"]] == ["reviewer-claude-01", "reviewer-grok-01"]
|
||||
assert plan["targets"][0] == {
|
||||
"name": "reviewer-claude-01",
|
||||
"agent": "claude",
|
||||
"role": "planner,reviewer",
|
||||
"herdr_server": "srv1",
|
||||
"herdr_workspace": "lbl1",
|
||||
"old_id": UA,
|
||||
"old_created_at": "2026-10-01T00:00:00Z",
|
||||
}
|
||||
skipped = {s["name"]: s["reason"] for s in plan["skipped"]}
|
||||
assert "not the control root" in skipped["creator-claude-wt"]
|
||||
assert skipped["stopped-codex-01"] == "status stopped"
|
||||
assert skipped["dead-hermes-01"] == "status terminated"
|
||||
assert plan["orchestrators"] == [UORC]
|
||||
assert plan["errors"] == []
|
||||
|
||||
# Empty role or unknown agent on a running control-root row causes plan & precheck to return 1
|
||||
bad_state = {
|
||||
"herdr_sessions": [
|
||||
_row("bad-role-claude", "claude", ws, role="", own_id=UA),
|
||||
{
|
||||
"name": "mystery-row",
|
||||
"status": "running",
|
||||
"role": "reviewer",
|
||||
"agent": "not-an-agent",
|
||||
"pane": {"cwd": str(ws), "pid": 123, "cmd": "bash"},
|
||||
},
|
||||
]
|
||||
}
|
||||
res_bad = _run_reset_py(["plan", "--workspace", str(ws)], state=bad_state)
|
||||
assert res_bad.returncode == 1
|
||||
bad_plan = json.loads(res_bad.stdout)
|
||||
assert len(bad_plan["errors"]) == 2
|
||||
|
||||
res_pre = _run_reset_py(["precheck", "--workspace", str(ws)], state=bad_state)
|
||||
assert res_pre.returncode == 1
|
||||
assert "bad-role-claude" in res_pre.stdout
|
||||
assert "mystery-row" in res_pre.stdout
|
||||
|
||||
|
||||
def test_spec_file_write_read_and_validation(tmp_path: Path):
|
||||
"""D5: write-spec atomically writes .mam/reset-spec.json; corrupt, wrong-version,
|
||||
or wrong-workspace spec files are rejected with 'check it, then remove it by hand'.
|
||||
"""
|
||||
from lib_py.reset import SpecError, read_spec
|
||||
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
targets = [
|
||||
{
|
||||
"name": "reviewer-claude-01",
|
||||
"agent": "claude",
|
||||
"role": "reviewer",
|
||||
"herdr_server": "default",
|
||||
"herdr_workspace": "ws",
|
||||
"old_id": UA,
|
||||
"old_created_at": "2026-10-01T00:00:00Z",
|
||||
}
|
||||
]
|
||||
res = _run_reset_py(
|
||||
["write-spec", "--workspace", str(ws)],
|
||||
stdin_text=json.dumps({"targets": targets}),
|
||||
)
|
||||
assert res.returncode == 0, res.stderr
|
||||
spath = ws / ".mam" / "reset-spec.json"
|
||||
assert spath.is_file()
|
||||
doc = read_spec(str(ws))
|
||||
assert doc["version"] == 1
|
||||
assert doc["workspace"] == os.path.realpath(str(ws))
|
||||
assert doc["agents"] == targets
|
||||
|
||||
# Wrong workspace
|
||||
other_ws = tmp_path / "other"
|
||||
other_ws.mkdir()
|
||||
with pytest.raises(SpecError, match="check it, then remove it by hand"):
|
||||
read_spec(str(other_ws))
|
||||
|
||||
# Wrong version
|
||||
spath.write_text(json.dumps({"version": 99, "workspace": str(ws), "agents": []}))
|
||||
with pytest.raises(SpecError, match="check it, then remove it by hand"):
|
||||
read_spec(str(ws))
|
||||
|
||||
# Corrupt JSON
|
||||
spath.write_text("{corrupt")
|
||||
with pytest.raises(SpecError, match="check it, then remove it by hand"):
|
||||
read_spec(str(ws))
|
||||
res_entries = _run_reset_py(["entries", "--workspace", str(ws)], state={})
|
||||
assert res_entries.returncode == 1
|
||||
assert "check it, then remove it by hand" in res_entries.stderr
|
||||
|
||||
|
||||
def test_entries_5_states_and_resumed_row_9b(tmp_path: Path):
|
||||
"""D6 1-5 & §5.2-9b:
|
||||
#1 row absent -> purged
|
||||
#2 same created_at (even if normal stop recorded a new id onto old_id=None) -> pending
|
||||
#3 new created_at and id == old_id -> pending (resumed original conversation, 9b)
|
||||
#4 new created_at and id empty -> created
|
||||
#5 new created_at and id != old_id -> done
|
||||
"""
|
||||
from lib_py.reset import write_spec
|
||||
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
spec_agents = [
|
||||
{
|
||||
"name": "s1-purged",
|
||||
"agent": "claude",
|
||||
"role": "reviewer",
|
||||
"herdr_server": "default",
|
||||
"herdr_workspace": "ws",
|
||||
"old_id": UA,
|
||||
"old_created_at": "2026-10-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"name": "s2-same-inst-newly-recorded",
|
||||
"agent": "agy",
|
||||
"role": "reviewer",
|
||||
"herdr_server": "default",
|
||||
"herdr_workspace": "ws",
|
||||
"old_id": None,
|
||||
"old_created_at": "2026-10-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"name": "s3-resumed-same-id",
|
||||
"agent": "claude",
|
||||
"role": "reviewer",
|
||||
"herdr_server": "default",
|
||||
"herdr_workspace": "ws",
|
||||
"old_id": UB,
|
||||
"old_created_at": "2026-10-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"name": "s4-created-no-id",
|
||||
"agent": "hermes",
|
||||
"role": "reviewer",
|
||||
"herdr_server": "default",
|
||||
"herdr_workspace": "ws",
|
||||
"old_id": UC,
|
||||
"old_created_at": "2026-10-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"name": "s5-done",
|
||||
"agent": "codex",
|
||||
"role": "reviewer",
|
||||
"herdr_server": "default",
|
||||
"herdr_workspace": "ws",
|
||||
"old_id": None,
|
||||
"old_created_at": "2026-10-01T00:00:00Z",
|
||||
},
|
||||
]
|
||||
write_spec(str(ws), spec_agents)
|
||||
|
||||
state = {
|
||||
"herdr_sessions": [
|
||||
# #2: normal stop wrote UA onto original row (same created_at, status stopped) -> still pending!
|
||||
_row(
|
||||
"s2-same-inst-newly-recorded",
|
||||
"agy",
|
||||
ws,
|
||||
status="stopped",
|
||||
created_at="2026-10-01T00:00:00Z",
|
||||
own_id=UA,
|
||||
),
|
||||
# #3 (9b): resumed into a new herdr session (different created_at), but same conversation ID UB -> pending!
|
||||
_row(
|
||||
"s3-resumed-same-id",
|
||||
"claude",
|
||||
ws,
|
||||
status="running",
|
||||
created_at="2026-10-02T00:00:00Z",
|
||||
own_id=UB,
|
||||
),
|
||||
# #4: newly created session, ID not discovered yet -> created
|
||||
_row(
|
||||
"s4-created-no-id",
|
||||
"hermes",
|
||||
ws,
|
||||
status="running",
|
||||
created_at="2026-10-02T00:00:00Z",
|
||||
own_id=None,
|
||||
),
|
||||
# #5: newly created session, new ID UD recorded (old_id was None) -> done
|
||||
_row(
|
||||
"s5-done",
|
||||
"codex",
|
||||
ws,
|
||||
status="running",
|
||||
created_at="2026-10-02T00:00:00Z",
|
||||
own_id=UD,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
res = _run_reset_py(["entries", "--workspace", str(ws)], state=state)
|
||||
assert res.returncode == 0, res.stderr
|
||||
parsed = {}
|
||||
for line in res.stdout.strip().splitlines():
|
||||
kv = dict(tok.split("=", 1) for tok in shlex.split(line))
|
||||
parsed[kv["R_NAME"]] = kv
|
||||
|
||||
assert parsed["s1-purged"]["R_STATE"] == "purged"
|
||||
assert parsed["s2-same-inst-newly-recorded"]["R_STATE"] == "pending"
|
||||
assert parsed["s2-same-inst-newly-recorded"]["R_ROW_ID"] == UA
|
||||
assert parsed["s3-resumed-same-id"]["R_STATE"] == "pending"
|
||||
assert parsed["s4-created-no-id"]["R_STATE"] == "created"
|
||||
assert parsed["s5-done"]["R_STATE"] == "done"
|
||||
|
||||
# Check D8 table formatting for these 5 rows (including 9b NEW!=OLD == "no" and 3-1 OLD_ID="-", NEW!=OLD="yes")
|
||||
res_tbl = _run_reset_py(
|
||||
[
|
||||
"table",
|
||||
"--workspace",
|
||||
str(ws),
|
||||
"--skipped",
|
||||
"s2-same-inst-newly-recorded:purge_refused",
|
||||
],
|
||||
state=state,
|
||||
)
|
||||
assert res_tbl.returncode == 4 # 4 takes precedence over 5
|
||||
rows_by_name = {}
|
||||
for line in res_tbl.stdout.strip().splitlines()[1:]:
|
||||
cols = line.split(None, 6)
|
||||
rows_by_name[cols[0]] = cols
|
||||
|
||||
# s2: original row where step 1 captured UA, then step 3 refused purge -> NEW_ID is '-', RECORDED 'no', NEW!=OLD '-'
|
||||
assert rows_by_name["s2-same-inst-newly-recorded"][1:6] == ["agy", "-", "-", "no", "-"]
|
||||
assert rows_by_name["s2-same-inst-newly-recorded"][6] == "skipped: purge refused"
|
||||
|
||||
# s3 (9b): resumed row with same ID UB -> NEW_ID is UB, RECORDED 'yes', NEW!=OLD 'no'
|
||||
assert rows_by_name["s3-resumed-same-id"][1:6] == ["claude", UB, UB, "yes", "no"]
|
||||
|
||||
# s4: created, no ID yet -> NEW_ID '-', RECORDED 'no', NEW!=OLD '-', RESULT 'created; id not recorded yet'
|
||||
assert rows_by_name["s4-created-no-id"][1:6] == ["hermes", UC, "-", "no", "-"]
|
||||
assert rows_by_name["s4-created-no-id"][6] == "created; id not recorded yet"
|
||||
|
||||
# s5 (3-1): old_id was None, new_id is UD -> OLD_ID '-', NEW_ID UD, RECORDED 'yes', NEW!=OLD 'yes', RESULT 'done'
|
||||
assert rows_by_name["s5-done"][1:6] == ["codex", "-", UD, "yes", "yes"]
|
||||
assert rows_by_name["s5-done"][6] == "done"
|
||||
|
||||
|
||||
def test_precheck_open_jobs_and_caller_target(tmp_path: Path):
|
||||
"""D4-5: precheck reports open jobs with close command and refuses when caller is a target."""
|
||||
ws = tmp_path / "ws"
|
||||
jobs_dir = ws / ".mam" / "jobs"
|
||||
jobs_dir.mkdir(parents=True)
|
||||
(jobs_dir / "j123.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"job_id": "j123",
|
||||
"agent_session": "herdr:reviewer-claude-01",
|
||||
"status": "running",
|
||||
}
|
||||
)
|
||||
)
|
||||
state = {
|
||||
"herdr_sessions": [
|
||||
_row("reviewer-claude-01", "claude", ws, own_id=UA, pid=os.getpid()),
|
||||
]
|
||||
}
|
||||
res = _run_reset_py(
|
||||
["precheck", "--workspace", str(ws)],
|
||||
state=state,
|
||||
env_extra={"CLAUDE_CODE_SESSION_ID": UA},
|
||||
)
|
||||
assert res.returncode == 1
|
||||
assert "job j123" in res.stdout
|
||||
assert "close --job j123 --request cancelled" in res.stdout
|
||||
assert "you are reviewer-claude-01, one of the targets; run the reset from the orchestrator" in res.stdout
|
||||
Reference in new issue
Block a user