d198dd5fixed one helper; the same defect was in eleven more modules (reviews grok and codex R1/R2): their shells inherited the process environment, so each `source lib.sh` rewrote <WORKSPACE_ROOT>/.mam/shim/herdr - the checkout's own when the variable is unset or names this checkout (which pytest_configure keeps), another workspace's under --noconftest. - conftest: own_workspace_root, an opt-in fixture setting WORKSPACE_ROOT to <tmp_path>/.mam-test-state/workspace. Not autouse: runner.py and mqtt_common read WORKSPACE_ROOT in-process, and some tests unset it on purpose. mam_sandbox and explicit envs run later and still win. - pytestmark usefixtures("own_workspace_root") in test_version_consistency, test_b19_headless_reconcile_fixes, test_b8_send_keys_verification, test_issue6_fail_closed, test_loop_cli, test_issue5_phase3_loop, test_issue5_phase4_runtime, test_o3_scoped_guard, test_issue25_plan_reuse, test_orc_onboard (their shells inherit os.environ, many with no env at all). - test_orc_onboard (R2): SCRUBBED_ENV is taken at import and no longer carries WORKSPACE_ROOT; every call builds _scrubbed_env(root) with the root of the call (ws / cwd / mam_sandbox, else this test's). - test_workspace_scope (unittest) and test_issue21_worktree_prereqs._mutate pin the root they already create. Tests (test_issue17_workspace_root_isolation.py): W1 copies the checkout and runs one test of each of those modules there with WORKSPACE_ROOT naming the copy - the copy gets no .mam; W2 imports test_orc_onboard under a sentinel root, then calls it under another - the sentinel stays empty. Atd198dd5both fail (the copy gets .mam/shim/herdr; the sentinel gets it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
213 lines
7.3 KiB
Python
213 lines
7.3 KiB
Python
"""Tests for Issue #5 Phase 3: run_loop.sh Python port and thin wrapper.
|
|
|
|
Covers:
|
|
- Strict parser: code fence stripping, last non-blank line verdict detection, fail-closed
|
|
- Tag extraction: [ESCALATE: PLANNER], [REBUT: <rev>], [ADJUDICATION], [ARBITRATION]
|
|
- Rebuttal budget logic (iteration budget vs run-wide budget)
|
|
- Promotion atomic writes
|
|
- Checkpoint save / load / remove
|
|
- Thin wrapper CLI compatibility, delegation and rollback (MAM_LOOP_PY=0/1)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKILLS = REPO_ROOT / ".agents" / "skills"
|
|
RUN_LOOP_SH = SKILLS / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
|
|
|
if str(SKILLS) not in sys.path:
|
|
sys.path.insert(0, str(SKILLS))
|
|
|
|
from lib_py.loop import parser, checkpoint, runner
|
|
|
|
# Issue #17: shells started here inherit a WORKSPACE_ROOT of this test's own
|
|
# (lib.sh would otherwise rewrite the checkout's .mam/shim/herdr).
|
|
pytestmark = pytest.mark.usefixtures("own_workspace_root")
|
|
|
|
|
|
def test_p3_01_strict_verdict_ignores_fenced_code():
|
|
"""P3-01: A verdict token inside markdown code fences must NOT be matched."""
|
|
content = """# Review Report
|
|
The implementation has issues:
|
|
```markdown
|
|
Here is how to write a verdict:
|
|
[VERDICT: PASS]
|
|
```
|
|
Please fix the above lint errors.
|
|
|
|
[VERDICT: NOT PASS]
|
|
"""
|
|
assert parser.parse_verdict(content) == "NOT PASS"
|
|
|
|
|
|
def test_p3_01_strict_verdict_ignores_trailing_fenced_pass():
|
|
"""P3-01: If the report ends with a code fence containing [VERDICT: PASS], it must be ignored."""
|
|
content = """# Review Report
|
|
Here is a demonstration snippet:
|
|
```
|
|
[VERDICT: PASS]
|
|
```
|
|
"""
|
|
assert parser.parse_verdict(content) is None
|
|
|
|
|
|
def test_p3_01_strict_verdict_last_non_blank_line():
|
|
"""P3-01: Verdict must occupy the last non-blank line outside code fences."""
|
|
content = """# Review Report
|
|
Everything is verified cleanly.
|
|
|
|
[VERDICT: PASS]
|
|
|
|
|
|
"""
|
|
assert parser.parse_verdict(content) == "PASS"
|
|
|
|
|
|
def test_p3_01_strict_verdict_mid_text_ignored():
|
|
"""P3-01: A verdict token quoted mid-text is not matched if followed by commentary."""
|
|
content = """# Review Report
|
|
I initially thought [VERDICT: PASS] was warranted.
|
|
However, further tests revealed a deadlock.
|
|
"""
|
|
assert parser.parse_verdict(content) is None
|
|
|
|
|
|
def test_p3_01_has_tag_line_ignores_fenced_tags():
|
|
"""P3-01: [ESCALATE: PLANNER] inside code fences must not trigger escalation."""
|
|
content = """# Review Report
|
|
We discussed whether to escalate:
|
|
```
|
|
[ESCALATE: PLANNER]
|
|
```
|
|
We decided simple bugfix is sufficient.
|
|
|
|
[VERDICT: NOT PASS]
|
|
"""
|
|
assert not parser.has_tag_line(content, "ESCALATE: PLANNER")
|
|
assert parser.has_tag_line(content + "\n[ESCALATE: PLANNER]\n", "ESCALATE: PLANNER")
|
|
|
|
|
|
def test_p3_01_extract_tag_values_deduplicates_and_ignores_fences():
|
|
"""P3-01: extract_tag_values returns values outside fences, trimmed and clean."""
|
|
content = """# Correction Report
|
|
```
|
|
[REBUT: fake-reviewer]
|
|
```
|
|
I accept reviewer-01's first finding, but object to the second.
|
|
[REBUT: reviewer-01]
|
|
And also to reviewer-02:
|
|
[REBUT: reviewer-02 ]
|
|
"""
|
|
values = parser.extract_tag_values(content, "REBUT")
|
|
assert values == ["reviewer-01", "reviewer-02"]
|
|
|
|
|
|
def test_p3_01_adjudication_and_arbitration_tags():
|
|
"""P3-01: Adjudication and arbitration tags parsed strictly."""
|
|
s_sustained = "Re-adjudication:\n\n[ADJUDICATION: SUSTAINED]\n"
|
|
s_overruled = "Re-adjudication:\n\n[ADJUDICATION: OVERRULED]\n"
|
|
assert parser.has_tag_line(s_sustained, "ADJUDICATION: SUSTAINED")
|
|
assert not parser.has_tag_line(s_sustained, "ADJUDICATION: OVERRULED")
|
|
assert parser.has_tag_line(s_overruled, "ADJUDICATION: OVERRULED")
|
|
|
|
arb_creator = "Arbitration report:\n\n[ARBITRATION: CREATOR]\n"
|
|
arb_rev = "Arbitration report:\n\n[ARBITRATION: REVIEWER]\n"
|
|
assert parser.has_tag_line(arb_creator, "ARBITRATION: CREATOR")
|
|
assert parser.has_tag_line(arb_rev, "ARBITRATION: REVIEWER")
|
|
|
|
|
|
def test_p3_01_checkpoint_atomic_roundtrip(tmp_path):
|
|
"""P3-01: Checkpoint writes atomically and loads cleanly."""
|
|
cp_file = tmp_path / "loop-checkpoint.json"
|
|
data = {
|
|
"iteration": 2,
|
|
"created_jobs": ["job-1", "job-2"],
|
|
"current_plan": "Step 1: Done\nStep 2: Testing",
|
|
"review_status": {"reviewer-1": "NOT PASS"},
|
|
"base_commit": "abc1234",
|
|
}
|
|
checkpoint.save_checkpoint(cp_file, data)
|
|
assert cp_file.exists()
|
|
|
|
loaded = checkpoint.load_checkpoint(cp_file)
|
|
assert loaded == data
|
|
|
|
checkpoint.remove_checkpoint(cp_file)
|
|
assert not cp_file.exists()
|
|
assert checkpoint.load_checkpoint(cp_file) is None
|
|
|
|
|
|
def test_p3_02_run_loop_wrapper_help_lists_same_as_bash():
|
|
"""P3-02: run_loop.sh --help produces identical usage instructions and exits 1."""
|
|
res = subprocess.run(["bash", str(RUN_LOOP_SH), "--help"], capture_output=True, text=True)
|
|
assert res.returncode == 1
|
|
assert "Usage:" in res.stdout and "[options] --creator <agent-session-name> --task <goal-text>" in res.stdout
|
|
assert "--all-reviewer" in res.stdout
|
|
assert "--max-rebut" in res.stdout
|
|
|
|
|
|
def test_p3_02_run_loop_wrapper_delegates_to_python_default(tmp_path):
|
|
"""P3-02: Under MAM_LOOP_PY=1, run_loop.sh executes python lib_py.loop and reaches Target Agent check."""
|
|
marker = tmp_path / "loop-marker"
|
|
env = dict(os.environ)
|
|
env["MAM_LOOP_NO_FREEZE"] = "1"
|
|
env["MAM_LOOP_MARKER"] = str(marker)
|
|
env["MAM_LOOP_PY"] = "1"
|
|
res = subprocess.run(
|
|
["bash", str(RUN_LOOP_SH), "--creator", "nonexistent-agent", "--task", "test-goal"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
cwd=str(REPO_ROOT),
|
|
)
|
|
assert res.returncode == 1
|
|
assert "Target agent session 'nonexistent-agent' is not registered" in (res.stdout + res.stderr)
|
|
|
|
|
|
def test_p3_02_run_loop_wrapper_rollback_mode(tmp_path):
|
|
"""P3-02: Under MAM_LOOP_PY=0, run_loop.sh executes legacy bash loop without launching python."""
|
|
marker = tmp_path / "loop-marker"
|
|
env = dict(os.environ)
|
|
env["MAM_LOOP_NO_FREEZE"] = "1"
|
|
env["MAM_LOOP_MARKER"] = str(marker)
|
|
env["MAM_LOOP_PY"] = "0"
|
|
res = subprocess.run(
|
|
["bash", str(RUN_LOOP_SH), "--creator", "nonexistent-agent", "--task", "test-goal"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
cwd=str(REPO_ROOT),
|
|
)
|
|
assert res.returncode == 1
|
|
assert "Target agent session 'nonexistent-agent' is not registered" in (res.stdout + res.stderr)
|
|
|
|
|
|
def test_p3_03_runner_agent_type_resolution():
|
|
"""P3-03: LoopRunner resolves agent types accurately."""
|
|
r = runner.LoopRunner(["--creator", "test-c", "--task", "test-t"])
|
|
state = {
|
|
"herdr_sessions": [
|
|
{"name": "agent-1", "agent": "grok"},
|
|
{"name": "agent-2", "pane": {"cmd": "opencode"}},
|
|
{"name": "agent-codex-01"},
|
|
{"name": "agent-hermes-01"},
|
|
{"name": "agent-agy-01"},
|
|
{"name": "agent-other-01"},
|
|
]
|
|
}
|
|
assert r.resolve_agent_type("agent-1", state) == "grok"
|
|
assert r.resolve_agent_type("agent-2", state) == "opencode"
|
|
assert r.resolve_agent_type("agent-codex-01", state) == "codex"
|
|
assert r.resolve_agent_type("agent-hermes-01", state) == "hermes"
|
|
assert r.resolve_agent_type("agent-agy-01", state) == "agy"
|
|
assert r.resolve_agent_type("agent-other-01", state) == "claude"
|