engine.transition is now the only writer of job status. It implements the target table in state-transitions.md §3: claim, non-terminal metadata, explicit terminals, idempotent re-receipt, refusal of conflicting or late signals with an audit entry, stale-attempt rejection, and deadline timeout. Two write paths become one. update_job_status merged fields unconditionally; pick_pending hand-rolled a correct pending->running swap plus its own audit mirror. Neither was wrong alone, but a rule added to one silently missed the other, which is the shape that produced the phase 1 transport bugs. Both keep their signatures and their preserved legacy bodies behind MAM_ENGINE_CAS=0. A merged `status` value is routed to the request that owns it, so a raw field write can no longer slip past the table. Getting that wrong first was caught by the characterization tests: a metadata merge was overwriting the caller's status with the pre-merge value. Lock discipline is now enforced, not described. The lock covers a decision and one record write; audit mirroring is queued and flushed after release, carrying a monotonic transition_seq so ordering survives the move. A test watches for subprocess, socket and sleep calls while the lock is held and asserts none. Structural correction to my own plan, caught by a phase 1 guard: the plan put the engine in lib_py/loop, but delegation would then have had to import a sibling, which is exactly what mixes trees under a frozen loop. The engine belongs with the registry writers, so it lives in lib_py/delegation and loop consumes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
359 lines
14 KiB
Python
359 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Issue #5 Phase 2a — lib_py/loop core and shadow watcher.
|
|
|
|
Written in the order the plan fixes: characterize the current writers BEFORE
|
|
routing them through a CAS boundary, so the refactor has an oracle rather than a
|
|
promise. Phase 1 proved the value of that ordering; it also proved that a fake
|
|
which cannot express a failure hides it, so every double here models the real
|
|
contract (herdr's exit/stream split, the registry lock, the record on disk).
|
|
|
|
Sections
|
|
1. X-01..X-04 current writer behaviour (must hold before and after P2A-01).
|
|
2. engine the single CAS transition boundary and its lock discipline.
|
|
3. watcher herdr agent wait wrapping, stream split, reconnect backoff.
|
|
4. policy decisions checked against the phase-0 scenario contracts.
|
|
5. shadow the runner that cannot write, proven by hashing the registry.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKILLS = REPO_ROOT / ".agents" / "skills"
|
|
WATCHER_FIXTURES = REPO_ROOT / "tests" / "fixtures" / "issue_5" / "watcher"
|
|
|
|
CAS_MODES = ("0", "1") # MAM_ENGINE_CAS: legacy unconditional merge / CAS engine
|
|
|
|
|
|
# ==============================================================================
|
|
# Helpers
|
|
# ==============================================================================
|
|
|
|
def _reg(tmp_path) -> str:
|
|
d = tmp_path / ".mam" / "jobs"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return str(d)
|
|
|
|
|
|
def _make_job(registry_dir, **over):
|
|
from lib_py.delegation import registry
|
|
job_id = registry.register_job(over.pop("prompt", "p"), registry_dir=registry_dir, **over)
|
|
return job_id
|
|
|
|
|
|
def _load(registry_dir, job_id):
|
|
from lib_py.delegation import mqtt_common
|
|
return mqtt_common.load_job(job_id, registry_dir)
|
|
|
|
|
|
def _dir_fingerprint(path: Path) -> dict:
|
|
"""File name -> sha256 for every file under path. Detects any byte change."""
|
|
out = {}
|
|
for p in sorted(Path(path).rglob("*")):
|
|
if p.is_file():
|
|
out[str(p.relative_to(path))] = hashlib.sha256(p.read_bytes()).hexdigest()
|
|
return out
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _cas_on(monkeypatch):
|
|
"""Default every test to the CAS engine unless it says otherwise."""
|
|
monkeypatch.setenv("MAM_ENGINE_CAS", "1")
|
|
|
|
|
|
# ==============================================================================
|
|
# 1. X-01..X-04 — the writers as they behave today
|
|
# ==============================================================================
|
|
|
|
@pytest.mark.parametrize("mode", CAS_MODES)
|
|
def test_x01_update_job_status_merges_arbitrary_fields(mam_sandbox, tmp_path, monkeypatch, mode):
|
|
"""X-01: update_job_status merges whatever it is given, in both modes.
|
|
|
|
Its signature, return value and side effects are load-bearing: phase 1's
|
|
G-1..G-4 assert through it, so routing it through the engine must not change
|
|
what a caller observes."""
|
|
monkeypatch.setenv("MAM_ENGINE_CAS", mode)
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import mqtt_common
|
|
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
out = mqtt_common.update_job_status(job_id, reg, status="running", iteration=4)
|
|
assert out["status"] == "running"
|
|
assert out["iteration"] == 4
|
|
assert out["updated_at"], "updated_at must always be refreshed"
|
|
assert _load(reg, job_id)["status"] == "running"
|
|
|
|
|
|
@pytest.mark.parametrize("mode", CAS_MODES)
|
|
def test_x02_pick_pending_claims_only_pending_and_only_once(mam_sandbox, tmp_path, monkeypatch, mode):
|
|
"""X-02: the claim path's real contract.
|
|
|
|
pick_pending is not broken today: it filters on pending under the lock, so
|
|
pending->running is already a correct swap. It is a *duplicate* of the
|
|
chokepoint, not a hole in it. Routing it through the engine must preserve
|
|
exactly this behaviour."""
|
|
monkeypatch.setenv("MAM_ENGINE_CAS", mode)
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import registry
|
|
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg, agent_session="herdr:s1")
|
|
assert registry.pick_pending("herdr:s1", reg) == job_id
|
|
assert _load(reg, job_id)["status"] == "running"
|
|
# already claimed -> no second winner
|
|
assert registry.pick_pending("herdr:s1", reg) is None
|
|
# a different session never sees another session's work
|
|
other = _make_job(reg, agent_session="herdr:s2")
|
|
assert registry.pick_pending("herdr:s1", reg) is None
|
|
assert registry.pick_pending("herdr:s2", reg) == other
|
|
|
|
|
|
@pytest.mark.parametrize("mode", CAS_MODES)
|
|
def test_x03_status_write_mirrors_into_the_audit_log(mam_sandbox, tmp_path, monkeypatch, mode):
|
|
"""X-03: a status change is mirrored to the audit log, in both modes.
|
|
|
|
P2A-01 moves that mirror out of the lock; it must not lose it."""
|
|
monkeypatch.setenv("MAM_ENGINE_CAS", mode)
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import mqtt_common
|
|
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
mqtt_common.update_job_status(job_id, reg, status="completed")
|
|
|
|
logged = mqtt_common.read_logged_status(job_id, mqtt_common.get_logs_dir())
|
|
assert logged and logged.get("status") == "completed"
|
|
events = list(mqtt_common.iter_logged_events(job_id, mqtt_common.get_logs_dir()))
|
|
changes = [e for e in events if e.get("event") == "status_changed"]
|
|
assert changes, "a status change must appear in the audit log"
|
|
assert changes[-1]["to"] == "completed"
|
|
|
|
|
|
@pytest.mark.parametrize("mode", CAS_MODES)
|
|
def test_x04_next_seq_and_register_are_unaffected(mam_sandbox, tmp_path, monkeypatch, mode):
|
|
"""X-04: the non-status writers keep their behaviour under either mode."""
|
|
monkeypatch.setenv("MAM_ENGINE_CAS", mode)
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import mqtt_common
|
|
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
assert _load(reg, job_id)["status"] == "pending"
|
|
assert mqtt_common.next_seq(job_id, reg) == 1
|
|
assert mqtt_common.next_seq(job_id, reg) == 2
|
|
assert _load(reg, job_id)["last_seq"] == 2
|
|
assert _load(reg, job_id)["status"] == "pending", "next_seq must not touch status"
|
|
|
|
|
|
# ==============================================================================
|
|
# 2. engine — the CAS table (state-transitions.md §3) and lock discipline
|
|
# ==============================================================================
|
|
|
|
def test_engine_pending_to_running_on_claim(mam_sandbox, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import engine
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
res = engine.transition(job_id, "claim", registry_dir=reg, expected_status="pending")
|
|
assert res.committed and res.from_status == "pending" and res.to_status == "running"
|
|
|
|
|
|
@pytest.mark.parametrize("request_", ["progress", "permission_required"])
|
|
def test_engine_non_terminal_requests_keep_running(mam_sandbox, tmp_path, monkeypatch, request_):
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import engine
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
engine.transition(job_id, "claim", registry_dir=reg)
|
|
res = engine.transition(job_id, request_, registry_dir=reg,
|
|
fields={"blocked_reason": "tool"})
|
|
assert res.committed
|
|
assert _load(reg, job_id)["status"] == "running", "must stay non-terminal"
|
|
assert _load(reg, job_id)["blocked_reason"] == "tool"
|
|
|
|
|
|
@pytest.mark.parametrize("terminal", ["completed", "error", "cancelled"])
|
|
def test_engine_explicit_terminal_commits(mam_sandbox, tmp_path, monkeypatch, terminal):
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import engine
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
engine.transition(job_id, "claim", registry_dir=reg)
|
|
res = engine.transition(job_id, terminal, registry_dir=reg)
|
|
assert res.committed and res.to_status == terminal
|
|
|
|
|
|
def test_engine_repeated_terminal_is_an_idempotent_noop(mam_sandbox, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import engine
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
engine.transition(job_id, "claim", registry_dir=reg)
|
|
engine.transition(job_id, "completed", registry_dir=reg)
|
|
before = _load(reg, job_id)
|
|
res = engine.transition(job_id, "completed", registry_dir=reg)
|
|
assert res.committed is False
|
|
assert "idempotent" in res.reason
|
|
assert _load(reg, job_id)["transition_seq"] == before["transition_seq"], "no second write"
|
|
|
|
|
|
@pytest.mark.parametrize("late", ["error", "cancelled", "progress"])
|
|
def test_engine_refuses_to_reverse_a_terminal_and_audits_the_conflict(
|
|
mam_sandbox, tmp_path, monkeypatch, late
|
|
):
|
|
"""A late or conflicting signal must never walk a terminal record backwards."""
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import mqtt_common
|
|
from lib_py.delegation import engine
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
engine.transition(job_id, "claim", registry_dir=reg)
|
|
engine.transition(job_id, "completed", registry_dir=reg)
|
|
|
|
res = engine.transition(job_id, late, registry_dir=reg, source="late-arrival")
|
|
assert res.committed is False
|
|
assert _load(reg, job_id)["status"] == "completed", "terminal must be preserved"
|
|
events = list(mqtt_common.iter_logged_events(job_id, mqtt_common.get_logs_dir()))
|
|
refusals = [e for e in events if e.get("event") == "conflict_refused"]
|
|
assert refusals, "a refused conflict must be auditable"
|
|
assert refusals[-1]["current"] == "completed"
|
|
|
|
|
|
def test_engine_rejects_a_stale_attempt(mam_sandbox, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import engine
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
engine.transition(job_id, "claim", registry_dir=reg, fields={"attempt_id": 2})
|
|
res = engine.transition(job_id, "completed", registry_dir=reg, expected_attempt=1)
|
|
assert res.committed is False
|
|
assert "stale attempt" in res.reason
|
|
assert _load(reg, job_id)["status"] == "running"
|
|
|
|
|
|
def test_engine_deadline_timeout_becomes_error(mam_sandbox, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import engine
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
engine.transition(job_id, "claim", registry_dir=reg)
|
|
res = engine.transition(job_id, "deadline_timeout", registry_dir=reg)
|
|
assert res.committed and res.to_status == "error"
|
|
|
|
|
|
def test_engine_precondition_failure_does_not_write(mam_sandbox, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import engine
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
engine.transition(job_id, "claim", registry_dir=reg) # now running
|
|
res = engine.transition(job_id, "claim", registry_dir=reg, expected_status="pending")
|
|
assert res.committed is False and "precondition failed" in res.reason
|
|
|
|
|
|
def test_engine_transition_seq_is_monotonic(mam_sandbox, tmp_path, monkeypatch):
|
|
"""The audit mirror runs outside the lock, so ordering rides on this counter."""
|
|
monkeypatch.chdir(mam_sandbox)
|
|
from lib_py.delegation import engine
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
seqs = []
|
|
for req in ("claim", "progress", "progress", "completed"):
|
|
engine.transition(job_id, req, registry_dir=reg)
|
|
seqs.append(_load(reg, job_id)["transition_seq"])
|
|
assert seqs == sorted(seqs) and len(set(seqs)) == len(seqs), seqs
|
|
|
|
|
|
def test_g5_no_blocking_io_while_the_registry_lock_is_held(mam_sandbox, tmp_path, monkeypatch):
|
|
"""G5: the lock may cover a decision and one record write, nothing else.
|
|
|
|
Watches the calls that can block on another process or the network and
|
|
asserts none of them happen between acquiring and releasing the lock."""
|
|
monkeypatch.chdir(mam_sandbox)
|
|
import socket
|
|
from lib_py.delegation import mqtt_common
|
|
from lib_py.delegation import engine
|
|
|
|
reg = _reg(tmp_path)
|
|
job_id = _make_job(reg)
|
|
|
|
held = {"depth": 0}
|
|
violations = []
|
|
real_lock = mqtt_common.registry_lock
|
|
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def watched_lock(registry_dir):
|
|
held["depth"] += 1
|
|
try:
|
|
with real_lock(registry_dir):
|
|
yield
|
|
finally:
|
|
held["depth"] -= 1
|
|
|
|
def guard(name):
|
|
def spy(*args, **kwargs):
|
|
if held["depth"] > 0:
|
|
violations.append(name)
|
|
raise AssertionError("{} must not run under the registry lock".format(name))
|
|
return spy
|
|
|
|
monkeypatch.setattr(mqtt_common, "registry_lock", watched_lock)
|
|
monkeypatch.setattr(engine.mqtt_common, "registry_lock", watched_lock, raising=False)
|
|
monkeypatch.setattr(subprocess, "run", guard("subprocess.run"))
|
|
monkeypatch.setattr(subprocess, "Popen", guard("subprocess.Popen"))
|
|
monkeypatch.setattr(socket, "socket", guard("socket.socket"))
|
|
real_sleep = time.sleep
|
|
|
|
def sleep_spy(seconds):
|
|
if held["depth"] > 0:
|
|
violations.append("time.sleep")
|
|
return real_sleep(0)
|
|
|
|
monkeypatch.setattr(time, "sleep", sleep_spy)
|
|
|
|
for req in ("claim", "progress", "completed", "completed", "error"):
|
|
engine.transition(job_id, req, registry_dir=reg)
|
|
|
|
assert not violations, "blocking calls under the lock: {}".format(violations)
|
|
|
|
|
|
def test_g6_every_record_writer_is_the_engine_or_a_declared_exception():
|
|
"""G6: no new bypass of the transition boundary can be added unnoticed."""
|
|
import ast
|
|
|
|
allowed = {
|
|
# creation, and the sequence counter, which never touches status
|
|
("registry.py", "register_job"),
|
|
("mqtt_common.py", "next_seq"),
|
|
("mqtt_common.py", "_update_job_status_legacy"),
|
|
("registry.py", "_pick_pending_legacy"),
|
|
("engine.py", "transition"),
|
|
}
|
|
found = set()
|
|
for path in list((SKILLS / "lib_py" / "delegation").glob("*.py")) + \
|
|
list((SKILLS / "lib_py" / "loop").glob("*.py")):
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.FunctionDef):
|
|
continue
|
|
for inner in ast.walk(node):
|
|
if isinstance(inner, ast.Call):
|
|
fn = inner.func
|
|
name = getattr(fn, "attr", getattr(fn, "id", None))
|
|
if name == "_atomic_write_record":
|
|
found.add((path.name, node.name))
|
|
assert found <= allowed, (
|
|
"job records are written outside the transition boundary: {}".format(found - allowed)
|
|
)
|