Implement full E2E, integration, component, and unit tests, and resolve all leftover tmux-to-herdr issues in UI and core scripts
This commit is contained in:
@@ -0,0 +1,734 @@
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import pytest
|
||||
import time
|
||||
import sys
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
|
||||
def run_mutation(mam_sandbox, mutation_str, env=None):
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
|
||||
run_env = dict(os.environ)
|
||||
if env:
|
||||
run_env.update(env)
|
||||
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
|
||||
return res
|
||||
|
||||
def get_mqtt_common(mam_sandbox):
|
||||
script_path = str(mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts")
|
||||
if script_path not in sys.path:
|
||||
sys.path.insert(0, script_path)
|
||||
import mqtt_common
|
||||
return mqtt_common
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 1: Create Session (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_create_schema_validation(mam_sandbox):
|
||||
"""Verify that atomic_dump_yaml validates the schema and rejects malformed formats."""
|
||||
# Try setting herdr_sessions to a dictionary instead of list
|
||||
mutation = "d['herdr_sessions'] = {}"
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert "VALIDATE: herdr_sessions is not a list" in res.stderr
|
||||
|
||||
# Try setting status to an invalid state
|
||||
mutation_invalid_status = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'session1',
|
||||
'status': 'invalidstatus',
|
||||
'pane': {}
|
||||
}]
|
||||
"""
|
||||
res2 = run_mutation(mam_sandbox, mutation_invalid_status)
|
||||
assert "VALIDATE:" in res2.stderr
|
||||
|
||||
def test_comp_create_role_immutability(mam_sandbox):
|
||||
"""Verify that once a role is defined in the sessions, it cannot be mutated."""
|
||||
# 1. Setup session with a role
|
||||
mutation_setup = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'session-imm-role',
|
||||
'status': 'running',
|
||||
'role': 'Creator',
|
||||
'pane': {}
|
||||
}]
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation_setup)
|
||||
assert res.returncode == 0
|
||||
|
||||
# 2. Try mutating the role
|
||||
mutation_bad = """
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == 'session-imm-role':
|
||||
s['role'] = 'Reviewer'
|
||||
"""
|
||||
res_bad = run_mutation(mam_sandbox, mutation_bad)
|
||||
assert res_bad.returncode != 0
|
||||
assert "role of session 'session-imm-role' cannot be modified" in res_bad.stderr
|
||||
|
||||
def test_comp_create_duplicate_running_ids(mam_sandbox):
|
||||
"""Verify that duplicate running conversation IDs across sessions are rejected."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [
|
||||
{
|
||||
'name': 'sess1',
|
||||
'status': 'running',
|
||||
'claude_session_id_own': 'uuid-1234',
|
||||
'pane': {}
|
||||
},
|
||||
{
|
||||
'name': 'sess2',
|
||||
'status': 'running',
|
||||
'claude_session_id_own': 'uuid-1234',
|
||||
'pane': {}
|
||||
}
|
||||
]
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode != 0
|
||||
assert "Duplicate running conversation ID" in res.stderr
|
||||
|
||||
def test_comp_create_isolation_folder_setup(mam_sandbox):
|
||||
"""Verify that provision_isolation correctly creates directories and symlinks."""
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
iso_root = mam_sandbox / "iso_home_test"
|
||||
|
||||
# Mock global claude credentials
|
||||
claude_cred = mam_sandbox / ".claude"
|
||||
claude_cred.mkdir(parents=True, exist_ok=True)
|
||||
(claude_cred / ".credentials.json").write_text('{"token": "xyz"}')
|
||||
|
||||
cmd_str = f"source {lib_path} && provision_isolation claude {iso_root}"
|
||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Verify symlink exists and points to the credentials
|
||||
cred_sym = iso_root / ".credentials.json"
|
||||
assert cred_sym.is_symlink()
|
||||
assert cred_sym.read_text() == '{"token": "xyz"}'
|
||||
|
||||
def test_comp_create_sqlite_tables_created(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""Verify that tables exist and contain records after a full create_session.sh run."""
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
|
||||
cmd = [
|
||||
"bash", str(script_path),
|
||||
"--workspace", str(mam_sandbox),
|
||||
"--agent", "claude",
|
||||
"--role", "Creator"
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
assert db_path.exists()
|
||||
|
||||
# Connect directly to the SQLite DB
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check tables
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
assert "state" in tables
|
||||
assert "sessions" in tables
|
||||
|
||||
# Check entries in sessions table
|
||||
cursor.execute("SELECT name, status, pane_cwd FROM sessions;")
|
||||
rows = cursor.fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0].endswith("-creator-claude")
|
||||
assert rows[0][1] == "running"
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 2: Resume Session (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_resume_config_restore(mam_sandbox):
|
||||
"""Verify that isolation details are preserved and can be read from the DB registry."""
|
||||
# Write a test session with isolation details
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-session-iso',
|
||||
'status': 'stopped',
|
||||
'pane': {'cwd': '/tmp'},
|
||||
'isolation': {
|
||||
'uuid': 'iso-uuid-789',
|
||||
'root': '/tmp/iso_root',
|
||||
'lever': 'claude_config_dir',
|
||||
'seeded': []
|
||||
}
|
||||
}]
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Read the configuration via resume_session.sh isolation resolution check
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
|
||||
# We run in dry-run/mock mode or just execute python command checking block
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
|
||||
# Call the python parsing block from resume_session.sh directly
|
||||
python_block = """
|
||||
import os, json, sqlite3
|
||||
name = "test-session-iso"
|
||||
db_path = "DB_PATH_PLACEHOLDER"
|
||||
conn = sqlite3.connect(db_path)
|
||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||
if row:
|
||||
import json
|
||||
s = json.loads(row[0])
|
||||
print(json.dumps(s.get('isolation') or {}))
|
||||
""".replace("DB_PATH_PLACEHOLDER", str(db_path))
|
||||
|
||||
res_py = subprocess.run(["python3", "-c", python_block], capture_output=True, text=True)
|
||||
assert res_py.returncode == 0
|
||||
data = json.loads(res_py.stdout)
|
||||
assert data["uuid"] == "iso-uuid-789"
|
||||
assert data["root"] == "/tmp/iso_root"
|
||||
|
||||
def test_comp_resume_metadata_read_integrity(mam_sandbox):
|
||||
"""Verify metadata read integrity from the SQLite DB by changing state outside YAML."""
|
||||
# Write initial YAML state
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'sess-integrity',
|
||||
'status': 'stopped',
|
||||
'pane': {'cwd': '/tmp'}
|
||||
}]
|
||||
"""
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Update SQLite directly to terminated
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("UPDATE sessions SET status='terminated' WHERE name='sess-integrity'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Read via atomic_dump_yaml check (which pulls from DB sessions table)
|
||||
# If the DB reading has integrity, d will contain sess-integrity as status 'terminated'
|
||||
mutation_check = """
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == 'sess-integrity':
|
||||
print(f"STATUS={s['status']}")
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation_check)
|
||||
assert "STATUS=terminated" in res.stdout
|
||||
|
||||
def test_comp_resume_update_yaml(mam_sandbox):
|
||||
"""Verify that update_yaml_resumed.sh cleans up stop fields and marks status running."""
|
||||
# Seed a stopped session with stop metadata
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-resumed-session-creator-claude',
|
||||
'status': 'stopped',
|
||||
'stopped_at': '2026-07-19T00:00:00Z',
|
||||
'stop_reason': 'manual_stop',
|
||||
'pane': {'cwd': '/tmp'}
|
||||
}]
|
||||
"""
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-resumed-session-creator-claude", "--uuid", "new-uuid-999"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Verify stopped fields are popped
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT data FROM sessions WHERE name='test-resumed-session-creator-claude'").fetchone()
|
||||
s = json.loads(row[0])
|
||||
assert s["status"] == "running"
|
||||
assert "stopped_at" not in s
|
||||
assert "stop_reason" not in s
|
||||
assert s["claude_session_id_own"] == "new-uuid-999"
|
||||
conn.close()
|
||||
|
||||
def test_comp_resume_find_workspace_uuid_tier1_own_id(mam_sandbox):
|
||||
"""Verify find_workspace_uuid resolves the per-row own ID properly."""
|
||||
# Write a running session with a claude_session_id_own
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'ws-own-creator-claude',
|
||||
'status': 'running',
|
||||
'claude_session_id_own': 'own-uuid-111',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
# Target session mode scopes resolution to the row
|
||||
cmd_str = f"source {lib_path} && find_workspace_uuid {mam_sandbox} claude ws-own-creator-claude"
|
||||
# Since we are mock-checking, own_exists function mock will query disk format.
|
||||
# Claude disk format requires a project file: projects/<key>/<uuid>.jsonl
|
||||
# Let's create it.
|
||||
key = str(mam_sandbox).replace('/', '-').replace('_', '-')
|
||||
proj_dir = mam_sandbox / ".claude" / "projects" / key
|
||||
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||
(proj_dir / "own-uuid-111.jsonl").write_text('{"sessionId": "own-uuid-111"}')
|
||||
|
||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
# Should resolve to own-uuid-111 because it exists in projects folder
|
||||
assert res.stdout.strip() == "own-uuid-111"
|
||||
|
||||
def test_comp_resume_find_workspace_uuid_tier2_disk_scan_claude(mam_sandbox):
|
||||
"""Verify find_workspace_uuid scans workspace on disk if own ID isn't set in the row."""
|
||||
# Seed session without own ID
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'ws-scan-creator-claude',
|
||||
'status': 'stopped',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Create JSONL file
|
||||
key = str(mam_sandbox).replace('/', '-').replace('_', '-')
|
||||
proj_dir = mam_sandbox / ".claude" / "projects" / key
|
||||
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||
(proj_dir / "scanned-uuid.jsonl").write_text('{"sessionId": "scanned-uuid"}')
|
||||
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
cmd_str = f"source {lib_path} && find_workspace_uuid {mam_sandbox} claude"
|
||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == "scanned-uuid"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 3: Stop Session (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_stop_safe_path_checking(mam_sandbox):
|
||||
"""Verify path guards block directory deletion if isolation path check fails."""
|
||||
# Mock a terminated session where isolation root is set outside .mam folder
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-purge-guard-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'},
|
||||
'isolation': {
|
||||
'uuid': 'some-uuid',
|
||||
'root': '/tmp/unauthorized_path_outside_mam'
|
||||
}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
# Attempt to purge. The python script should print "WARN: isolated home path check failed" and NOT crash
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-guard-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
assert "WARN: isolated home path check failed" in res.stdout
|
||||
|
||||
def test_comp_stop_sqlite_state_update(mam_sandbox):
|
||||
"""Verify that stop_session.sh updates state in SQLite to stopped."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-stop-sqlite-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-stop-sqlite-creator-claude"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Query database directly
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT status, data FROM sessions WHERE name='test-stop-sqlite-creator-claude'").fetchone()
|
||||
assert row[0] == "stopped"
|
||||
s = json.loads(row[1])
|
||||
assert s["status"] == "stopped"
|
||||
assert "stopped_at" in s
|
||||
assert s["stop_reason"] == "manual_stop"
|
||||
conn.close()
|
||||
|
||||
def test_comp_stop_purge_record_removal(mam_sandbox):
|
||||
"""Verify that purge completely removes the session record from DB."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-purge-record-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-record-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Check SQLite
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT COUNT(*) FROM sessions WHERE name='test-purge-record-creator-claude'").fetchone()
|
||||
assert row[0] == 0
|
||||
conn.close()
|
||||
|
||||
def test_comp_stop_lock_release(mam_sandbox):
|
||||
"""Verify SQLite lock is released after running atomic_dump_yaml."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-lock-release',
|
||||
'status': 'stopped',
|
||||
'pane': {'cwd': '/tmp'}
|
||||
}]
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Since res has returned, the lock should be free.
|
||||
# We test this by immediately acquiring a direct SQLite write connection
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path), timeout=0.1)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute("UPDATE sessions SET status='archived' WHERE name='test-lock-release'")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError as e:
|
||||
pytest.fail(f"Could not acquire SQLite lock: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_comp_stop_graceful_kill_chain(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""Verify that the graceful stop command fallback chain runs when herdr has-session is alive."""
|
||||
# Write a running session in YAML
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-graceful-chain-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER', 'pid': 12345}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Pre-populate herdr mock with the running session so it passes has-session check
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
state["agents"]["test-graceful-chain-creator-claude"] = {
|
||||
"status": "running",
|
||||
"agent": "claude",
|
||||
"cwd": str(mam_sandbox),
|
||||
"pid": 12345,
|
||||
"pane_id": "w1:p1",
|
||||
"command": "claude",
|
||||
"buffer": "Anthropic Claude Ready"
|
||||
}
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
# Run stop_session.sh. This should call send-keys first
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-graceful-chain-creator-claude"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
|
||||
# Verify mock herdr calls recorded the keys "/exit" sent
|
||||
calls = state.get("calls", [])
|
||||
|
||||
# Should see send-keys call
|
||||
assert any("send" in call and "/exit" in call for call in calls)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 4: Status Query (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_status_read_lock(mam_sandbox):
|
||||
"""Verify status.sh accesses SQLite database with a read lock, not writing."""
|
||||
# Seed DB first so it exists
|
||||
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
|
||||
# Run status.sh in a background sub-process
|
||||
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Verify no writing occurred by comparing DB file modified time
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
t1 = os.path.getmtime(db_path)
|
||||
|
||||
# Run status.sh again
|
||||
subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||
t2 = os.path.getmtime(db_path)
|
||||
|
||||
assert t1 == t2
|
||||
|
||||
def test_comp_status_drift_class_parsing(mam_sandbox):
|
||||
"""Verify status.sh correctly parses drifts returned by reconcile.sh."""
|
||||
# Write reconcile mock logic that outputs a drift JSON
|
||||
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
|
||||
drift_output = {
|
||||
"timestamp": "2026-07-19T00:00:00Z",
|
||||
"yaml_path": str(mam_sandbox / ".mam" / "agent-sessions.yaml"),
|
||||
"herdr_sessions_alive": ["test-session|default"],
|
||||
"herdr_confirmed": True,
|
||||
"drifts": [{"class": "A", "name": "test-session", "msg": "drift detected"}],
|
||||
"actions": []
|
||||
}
|
||||
|
||||
# Overwrite reconcile.sh to print this JSON directly
|
||||
reconcile_script.write_text(f"""#!/usr/bin/env bash
|
||||
echo '{json.dumps(drift_output)}'
|
||||
""")
|
||||
|
||||
# Seed YAML
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-session',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': '/tmp'}
|
||||
}]
|
||||
"""
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
assert data["sessions_detail"][0]["drift_classes"] == ["A"]
|
||||
|
||||
def test_comp_status_job_candidates_parsing(mam_sandbox):
|
||||
"""Verify status.sh reads job statuses from audit logs or registry JSONs."""
|
||||
# Write YAML session
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-job-session-creator-claude',
|
||||
'status': 'running',
|
||||
'delegate_job_id': 'job12345',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Create the status.json in both candidate folders to cover path resolutions
|
||||
job_logs_dir1 = mam_sandbox / ".mam" / "delegate_job_logs" / "job12345"
|
||||
job_logs_dir1.mkdir(parents=True, exist_ok=True)
|
||||
(job_logs_dir1 / "status.json").write_text('{"status": "completed"}')
|
||||
|
||||
job_logs_dir2 = mam_sandbox.parent / ".mam" / "delegate_job_logs" / "job12345"
|
||||
job_logs_dir2.mkdir(parents=True, exist_ok=True)
|
||||
(job_logs_dir2 / "status.json").write_text('{"status": "completed"}')
|
||||
|
||||
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
assert data["sessions_detail"][0]["job_status"] == "completed"
|
||||
|
||||
def test_comp_status_no_side_effects(mam_sandbox):
|
||||
"""Verify that running status.sh leaves YAML and DB unchanged."""
|
||||
# Seed DB first so it exists
|
||||
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
|
||||
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
|
||||
y_content_1 = yaml_path.read_text()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
d_content_1 = conn.execute("SELECT * FROM sessions").fetchall()
|
||||
conn.close()
|
||||
|
||||
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
subprocess.run(["bash", str(status_script)], capture_output=True, text=True)
|
||||
|
||||
y_content_2 = yaml_path.read_text()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
d_content_2 = conn.execute("SELECT * FROM sessions").fetchall()
|
||||
conn.close()
|
||||
|
||||
assert y_content_1 == y_content_2
|
||||
assert d_content_1 == d_content_2
|
||||
|
||||
def test_comp_status_yaml_to_json_structure(mam_sandbox):
|
||||
"""Verify YAML-to-JSON structure translation preserves all nested maps."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-struct-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {
|
||||
'index': 0,
|
||||
'pid': 1234,
|
||||
'cmd': 'claude',
|
||||
'cwd': '/tmp'
|
||||
}
|
||||
}]
|
||||
"""
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
session = data["sessions_detail"][0]
|
||||
assert session["pane_pid"] == 1234
|
||||
assert session["pane_cwd"] == "/tmp"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 5: Monitor/Reconcile (6 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_monitor_concurrency_lock(mam_sandbox):
|
||||
"""Verify that multiple subscriber reconciles cannot run concurrently."""
|
||||
# Reconcile subscribe script tries to acquire .mam/monitor.lock
|
||||
# Let's write a mock subscriber that acquires the lock
|
||||
workspace_root = mam_sandbox
|
||||
lock_file_path = workspace_root / ".mam" / "monitor.lock"
|
||||
|
||||
# Hold lock in Python
|
||||
import fcntl
|
||||
lock_file = open(lock_file_path, 'w')
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
# Now execute reconcile subscribe loop, it should exit immediately
|
||||
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
res = subprocess.run(["bash", str(reconcile_script), "--subscribe", "--idle-timeout", "1"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res.returncode == 0
|
||||
assert "MQTT Monitor: another subscriber is already running" in res.stdout
|
||||
|
||||
lock_file.close()
|
||||
|
||||
def test_comp_monitor_sqlite_journal_wal(mam_sandbox):
|
||||
"""Verify that SQLite database defaults to WAL mode on standard filesystems."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = []
|
||||
"""
|
||||
# Runs atomic_dump_yaml which sets journal mode
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode == 0
|
||||
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
conn.close()
|
||||
assert mode.lower() == "wal"
|
||||
|
||||
def test_comp_monitor_sqlite_journal_delete_nfs(mam_sandbox):
|
||||
"""Verify that SQLite database falls back to DELETE mode on NFS filesystems."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = []
|
||||
"""
|
||||
# Set MAM_IS_NFS env var
|
||||
res = run_mutation(mam_sandbox, mutation, env={"MAM_IS_NFS": "true"})
|
||||
assert res.returncode == 0
|
||||
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
conn.close()
|
||||
assert mode.lower() == "delete"
|
||||
|
||||
def test_comp_monitor_db_schema_auto_update(mam_sandbox):
|
||||
"""Verify that table structure and indexes are automatically created if DB is deleted."""
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
|
||||
mutation = """
|
||||
d['herdr_sessions'] = []
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode == 0
|
||||
|
||||
assert db_path.exists()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
assert "state" in tables
|
||||
assert "sessions" in tables
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='index';")
|
||||
indexes = [row[0] for row in cursor.fetchall()]
|
||||
assert "idx_sessions_pane_cwd" in indexes
|
||||
conn.close()
|
||||
|
||||
def test_comp_monitor_monotonic_seq_updates(mam_sandbox):
|
||||
"""Verify sequence monotonic updates in mqtt_common."""
|
||||
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||
|
||||
# Setup job JSON in jobs registry dir
|
||||
registry_dir = mam_sandbox / ".mam" / "jobs"
|
||||
registry_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
job_id = "job-seq-123"
|
||||
job_data = {
|
||||
"job_id": job_id,
|
||||
"status": "running",
|
||||
"last_seq": 0
|
||||
}
|
||||
with open(registry_dir / f"{job_id}.json", "w") as f:
|
||||
json.dump(job_data, f)
|
||||
|
||||
# Get sequence multiple times
|
||||
s1 = mqtt_common.next_seq(job_id, str(registry_dir))
|
||||
s2 = mqtt_common.next_seq(job_id, str(registry_dir))
|
||||
s3 = mqtt_common.next_seq(job_id, str(registry_dir))
|
||||
|
||||
assert s1 == 1
|
||||
assert s2 == 2
|
||||
assert s3 == 3
|
||||
|
||||
# Reload and check
|
||||
with open(registry_dir / f"{job_id}.json", "r") as f:
|
||||
loaded = json.load(f)
|
||||
assert loaded["last_seq"] == 3
|
||||
|
||||
def test_comp_monitor_auto_state_recovery(mam_sandbox, mock_herdr):
|
||||
"""Verify that reconcile.sh auto-terminates a session if herdr is confirmed dead."""
|
||||
# Write a running session in YAML
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-autorecovery-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Run reconcile.sh --once --emit-diff (which runs the dry-run, which does NOT write but reports)
|
||||
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
res = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res.returncode == 0
|
||||
|
||||
data = json.loads(res.stdout)
|
||||
drifts = data["drifts"]
|
||||
# Should detect drift class A: herdr gone
|
||||
assert any(dr["class"] == "A" and dr["name"] == "test-autorecovery-creator-claude" for dr in drifts)
|
||||
|
||||
# Now run reconcile.sh --once --emit-diff (without --dry-run) to trigger write
|
||||
res_write = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res_write.returncode == 0
|
||||
|
||||
# Verify session is marked terminated in the SQLite DB
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT status FROM sessions WHERE name='test-autorecovery-creator-claude'").fetchone()
|
||||
assert row[0] == "terminated"
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user