ouroboros/tests/test_acceptance_fence_transport.py
Ouroboros 101e5cd940 Never re-send a fence read
inspect is a read asked many times per turn and its loss is harmless (the last
known generation stays, the end compare-and-seal decides). Re-sending it doubled
the host blocking of every read under a stalled supervisor; only the transitions
(begin, end) keep their single re-send.
2026-09-21 05:11:55 +03:00

377 lines
17 KiB
Python

"""The acceptance-fence TRANSPORT: an answer that has not arrived is a gap.
Direct turns run inside the supervisor process and apply the fence in-process
(no event, no ack file, no wait). Pooled workers keep event + ack, made
idempotent: one token per logical begin, one ``req`` per request, the ack file
is ``<token>.<req>.json``, one re-send, then a typed outcome. The supervisor
re-adopts an ``active`` row of the SAME task with token rebinding, never a
``sealed`` one; a missing row answers ``released`` + ``row_absent`` and the
worker treats only ``sealed`` as a seal.
The pooled tests run the REAL worker seam, the REAL ack writer and the REAL
queue transition behind an emulated (slow / lossy) supervisor loop.
"""
from __future__ import annotations
import json
import queue as stdqueue
import threading
import time
from types import SimpleNamespace
import pytest
from tests.test_acceptance_fence import _isolated_queue
WAIT_SEC = 0.4
def _pooled_agent(tmp_path, events, task_id="root-1"):
from ouroboros.agent import Env, OuroborosAgent
repo = tmp_path / "repo"
repo.mkdir(exist_ok=True)
agent = object.__new__(OuroborosAgent)
agent.env = Env(repo_dir=repo, drive_root=tmp_path)
agent._current_task_metadata = {}
agent._current_task_id = task_id
agent._event_queue = events
return agent
class _Supervisor:
"""The supervisor's fence handler behind a consumer thread.
``drop_events``: the first N events are never applied (a loop that never got
to them). ``lose_acks``: the first N transitions ARE applied but their ack is
lost. ``paused``: events queue up until ``resume()`` (a stalled loop).
"""
def __init__(self, events, drive_root, *, drop_events=0, lose_acks=0, delay=0.0, paused=False):
self.events, self.drive_root = events, drive_root
self.drop_events, self.lose_acks, self.delay = drop_events, lose_acks, delay
self.seen: list = []
self._go = threading.Event()
if not paused:
self._go.set()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def resume(self):
self._go.set()
def _run(self):
from supervisor import queue as queue_mod
from supervisor.events_worker_reports import _handle_acceptance_fence
while True:
evt = self.events.get()
if evt is None:
return
self._go.wait(30)
self.seen.append(dict(evt))
if self.drop_events > 0:
self.drop_events -= 1
continue
time.sleep(self.delay)
if self.lose_acks > 0:
self.lose_acks -= 1
queue_mod.transition_acceptance_fence(**{
key: evt[key] for key in ("action", "token", "root_task_id", "task_id", "outcome", "expected_generation")
if key in evt
})
continue
_handle_acceptance_fence(evt, SimpleNamespace(DRIVE_ROOT=self.drive_root))
def drained(self, count, timeout=10.0):
deadline = time.monotonic() + timeout
while len(self.seen) < count and time.monotonic() < deadline:
time.sleep(0.01)
time.sleep(0.05)
return len(self.seen) >= count
def stop(self):
self._go.set()
self.events.put(None)
self._thread.join(timeout=10)
assert not self._thread.is_alive()
@pytest.fixture
def short_wait(monkeypatch):
from ouroboros import runtime_limits
monkeypatch.setattr(runtime_limits, "get_acceptance_fence_ack_wait_sec", lambda: WAIT_SEC)
def _ack_names(tmp_path):
ack_dir = tmp_path / "state" / "acceptance_fence_acks"
return sorted(path.name for path in ack_dir.glob("*.json")) if ack_dir.is_dir() else []
# --- supervisor: idempotent begin, real status, missing row --------------------------------
def test_begin_with_lost_token_readopts_existing_live_fence(monkeypatch, tmp_path):
"""The ack timed out AFTER the supervisor activated the fence: the same task's
next begin re-adopts the row and REBINDS it, so the dead attempt's token dies."""
queue_mod, _pending = _isolated_queue(monkeypatch, tmp_path)
begun = queue_mod.transition_acceptance_fence(
action="begin", token="a" * 32, root_task_id="root-1", task_id="root-1")
assert begun["status"] == "active"
queue_mod.ACCEPTANCE_FENCES["root-1"]["owner_message_generation"] = 2
readopted = queue_mod.transition_acceptance_fence(
action="begin", token="b" * 32, root_task_id="root-1", task_id="root-1")
assert (readopted["ok"], readopted["status"], readopted["token"]) == (True, "active", "b" * 32)
assert readopted["owner_message_generation"] == 2 # the row survives, only its token moves
assert queue_mod.ACCEPTANCE_FENCES["root-1"]["token"] == "b" * 32
assert len(queue_mod.ACCEPTANCE_FENCES) == 1
# A late event of the dead attempt finds no row: harmless, and it tears nothing down.
late = queue_mod.transition_acceptance_fence(action="end", token="a" * 32, outcome="revision")
assert (late["status"], late.get("row_absent")) == ("released", True)
assert queue_mod.ACCEPTANCE_FENCES["root-1"]["status"] == "active"
sealed = queue_mod.transition_acceptance_fence(
action="end", token="b" * 32, outcome="terminal", expected_generation=2)
assert sealed["status"] == "sealed"
def test_sealed_fence_is_never_readopted(monkeypatch, tmp_path):
queue_mod, _pending = _isolated_queue(monkeypatch, tmp_path)
queue_mod.transition_acceptance_fence(
action="begin", token="a" * 32, root_task_id="root-2", task_id="root-2")
assert queue_mod.transition_acceptance_fence(
action="end", token="a" * 32, outcome="terminal")["status"] == "sealed"
rejected = queue_mod.transition_acceptance_fence(
action="begin", token="c" * 32, root_task_id="root-2", task_id="root-2")
assert rejected["ok"] is False
assert rejected["status"] == "sealed" # the typed refusal carries the row's real status
assert "already sealed" in rejected["error"]
row = queue_mod.ACCEPTANCE_FENCES["root-2"]
assert (row["status"], row["token"]) == ("sealed", "a" * 32)
def test_begin_by_another_task_is_refused_and_no_dead_owner_is_collected(monkeypatch, tmp_path):
"""Re-adoption is for the SAME task only. The owner here has no RUNNING/PENDING
row at all — exactly how a LIVE direct root looks to the queue — and its fence
is still not collected for a different requester."""
queue_mod, _pending = _isolated_queue(monkeypatch, tmp_path)
queue_mod.transition_acceptance_fence(
action="begin", token="a" * 32, root_task_id="root-3", task_id="root-3")
foreign = queue_mod.transition_acceptance_fence(
action="begin", token="b" * 32, root_task_id="root-3", task_id="someone-else")
assert foreign["ok"] is False
assert queue_mod.ACCEPTANCE_FENCES["root-3"]["token"] == "a" * 32
assert queue_mod.ACCEPTANCE_FENCES["root-3"]["task_id"] == "root-3"
def test_same_token_begin_answers_the_rows_real_status(monkeypatch, tmp_path):
queue_mod, _pending = _isolated_queue(monkeypatch, tmp_path)
first = queue_mod.transition_acceptance_fence(
action="begin", token="a" * 32, root_task_id="root-4", task_id="root-4")
again = queue_mod.transition_acceptance_fence(
action="begin", token="a" * 32, root_task_id="root-4", task_id="root-4")
assert (first["status"], again["status"]) == ("active", "active")
queue_mod.transition_acceptance_fence(action="end", token="a" * 32, outcome="terminal")
resent = queue_mod.transition_acceptance_fence(
action="begin", token="a" * 32, root_task_id="root-4", task_id="root-4")
assert resent["status"] == "sealed"
@pytest.mark.parametrize("action,extra", [("inspect", {}), ("end", {"outcome": "revision"}), ("end", {"outcome": "terminal"})])
def test_missing_row_answers_released_row_absent(monkeypatch, tmp_path, action, extra):
"""Idempotent after ``task_done`` cleared the fence; a present row never says ``row_absent``."""
queue_mod, _pending = _isolated_queue(monkeypatch, tmp_path)
queue_mod.transition_acceptance_fence(
action="begin", token="a" * 32, root_task_id="root-5", task_id="root-5")
present = queue_mod.transition_acceptance_fence(action="inspect", token="a" * 32)
assert present["status"] == "active" and "row_absent" not in present
assert queue_mod.clear_acceptance_fence_for_root("root-5") is True
absent = queue_mod.transition_acceptance_fence(action=action, token="a" * 32, **extra)
assert (absent["ok"], absent["status"], absent["row_absent"]) == (True, "released", True)
assert queue_mod.ACCEPTANCE_FENCES == {}
# --- ack identity ---------------------------------------------------------------------------
def test_ack_file_is_keyed_by_token_and_request(monkeypatch, tmp_path):
from supervisor import queue
from supervisor.events_worker_reports import _handle_acceptance_fence
applied: list = []
monkeypatch.setattr(
queue, "transition_acceptance_fence",
lambda **kwargs: applied.append(kwargs) or {"ok": True, "status": "active"})
token, req = "a" * 32, "b" * 32
ctx = SimpleNamespace(DRIVE_ROOT=tmp_path)
_handle_acceptance_fence({"token": token, "req": req, "action": "begin", "root_task_id": "r", "task_id": "r"}, ctx)
assert _ack_names(tmp_path) == [f"{token}.{req}.json"]
# The token validator covers ``req``: a request id that could leave the ack
# directory, or none at all, is refused before any transition is applied.
for bad in ("../escape", "", "z" * 8, "c" * 65):
_handle_acceptance_fence({"token": token, "req": bad, "action": "inspect"}, ctx)
assert len(applied) == 1
assert _ack_names(tmp_path) == [f"{token}.{req}.json"]
def test_acceptance_ack_waiter_ignores_stale_operation_ack(monkeypatch, tmp_path, short_wait):
"""begin/inspect/end share one fence token, so an ack belongs to ONE request. A late
ack of another request is never consumed as this request's receipt."""
_isolated_queue(monkeypatch, tmp_path)
token = "a" * 32
ack_dir = tmp_path / "state" / "acceptance_fence_acks"
ack_dir.mkdir(parents=True)
stale = [ack_dir / f"{token}.json", ack_dir / f"{token}.{'d' * 32}.json"]
for path in stale:
path.write_text(json.dumps({"ok": True, "status": "active", "token": token}), encoding="utf-8")
events: stdqueue.Queue = stdqueue.Queue()
agent = _pooled_agent(tmp_path, events)
with pytest.raises(TimeoutError):
agent._inspect_acceptance_fence(token=token) # nobody answers THIS request
assert all(path.exists() for path in stale)
supervisor = _Supervisor(events, tmp_path)
try:
own = agent._end_acceptance_fence(token=token, outcome="revision")
finally:
supervisor.stop()
assert (own["status"], own["row_absent"]) == ("released", True) # its OWN answer, not a stale ``active``
# Its own ack is consumed; the stale ones — and the late answer to the timed-out
# inspect the supervisor has meanwhile caught up with — are read by nobody.
late_inspect = f"{token}.{supervisor.seen[0]['req']}.json"
assert supervisor.seen[0]["action"] == "inspect" and supervisor.seen[-1]["action"] == "end"
assert _ack_names(tmp_path) == sorted([path.name for path in stale] + [late_inspect])
def test_late_ack_of_a_previous_request_is_read_by_nobody(monkeypatch, tmp_path, short_wait):
"""evidence/h8 ``repro_stale_ack``: a timed-out inspect used to poison the NEXT
operation on the same token — its late ``active`` ack was read as the end's answer."""
queue_mod, _pending = _isolated_queue(monkeypatch, tmp_path)
events: stdqueue.Queue = stdqueue.Queue()
agent = _pooled_agent(tmp_path, events)
supervisor = _Supervisor(events, tmp_path)
try:
token = agent._begin_acceptance_fence(root_task_id="root-1", task_id="root-1")["token"]
supervisor.stop()
stalled = _Supervisor(events, tmp_path, paused=True)
with pytest.raises(TimeoutError):
agent._inspect_acceptance_fence(token=token)
stalled.resume() # the loop catches up and answers the inspect LATE
assert stalled.drained(1) # a read is sent once, never re-sent
late = _ack_names(tmp_path)
assert late and all(name.startswith(f"{token}.") for name in late)
ended = agent._end_acceptance_fence(token=token, outcome="terminal", expected_generation=0)
assert ended["status"] == "sealed" # the end's own answer, not the late ``active``
assert queue_mod.ACCEPTANCE_FENCES["root-1"]["status"] == "sealed"
assert _ack_names(tmp_path) == late # the late acks were read by nobody
stalled.stop()
finally:
events.put(None)
# --- pooled: one re-send, then a typed outcome ---------------------------------------------
def test_pooled_request_is_resent_once_with_the_same_identity(monkeypatch, tmp_path, short_wait):
queue_mod, _pending = _isolated_queue(monkeypatch, tmp_path)
events: stdqueue.Queue = stdqueue.Queue()
agent = _pooled_agent(tmp_path, events)
supervisor = _Supervisor(events, tmp_path, drop_events=1)
try:
ack = agent._begin_acceptance_fence(root_task_id="root-1", task_id="root-1")
finally:
supervisor.stop()
assert ack["status"] == "active"
assert len(supervisor.seen) == 2
first, second = supervisor.seen
assert (first["token"], first["req"]) == (second["token"], second["req"]) == (ack["token"], first["req"])
assert queue_mod.ACCEPTANCE_FENCES["root-1"]["token"] == ack["token"]
# A transition never answered: exactly ONE re-send, then a TimeoutError — no loop, no long wait.
silent: stdqueue.Queue = stdqueue.Queue()
started = time.monotonic()
with pytest.raises(TimeoutError):
_pooled_agent(tmp_path, silent)._end_acceptance_fence(token=ack["token"], outcome="revision")
assert time.monotonic() - started < WAIT_SEC * 2 + 2.0
sent = [silent.get_nowait() for _ in range(silent.qsize())]
assert len(sent) == 2 and sent[0]["req"] == sent[1]["req"]
# A READ (inspect is asked many times a turn; its loss is harmless) is never re-sent:
# one wait, one event — a stalled supervisor cannot multiply into minutes of host blocking.
quiet: stdqueue.Queue = stdqueue.Queue()
started = time.monotonic()
with pytest.raises(TimeoutError):
_pooled_agent(tmp_path, quiet)._inspect_acceptance_fence(token=ack["token"])
assert time.monotonic() - started < WAIT_SEC + 2.0
assert quiet.qsize() == 1
def test_every_request_carries_a_fresh_req_and_one_token_per_logical_begin(monkeypatch, tmp_path, short_wait):
_isolated_queue(monkeypatch, tmp_path)
events: stdqueue.Queue = stdqueue.Queue()
agent = _pooled_agent(tmp_path, events)
supervisor = _Supervisor(events, tmp_path)
try:
token = agent._begin_acceptance_fence(root_task_id="root-1", task_id="root-1")["token"]
agent._inspect_acceptance_fence(token=token)
agent._end_acceptance_fence(token=token, outcome="revision")
finally:
supervisor.stop()
assert [evt["action"] for evt in supervisor.seen] == ["begin", "inspect", "end"]
assert {evt["token"] for evt in supervisor.seen} == {token}
assert len({evt["req"] for evt in supervisor.seen}) == 3
assert _ack_names(tmp_path) == [] # every waiter consumed exactly its own ack
def test_ack_wait_is_a_named_runtime_limit():
from ouroboros import runtime_limits
assert runtime_limits.get_acceptance_fence_ack_wait_sec() == 10.0
# --- direct turns: no event, no ack file, no wait ------------------------------------------
def test_direct_turn_uses_no_event_and_no_ack_file(monkeypatch, tmp_path, short_wait):
queue_mod, _pending = _isolated_queue(monkeypatch, tmp_path)
events: stdqueue.Queue = stdqueue.Queue()
direct = _pooled_agent(tmp_path, events, task_id="direct-1")
direct.fence_transition = queue_mod.transition_acceptance_fence # what ``_get_chat_agent`` injects
started = time.monotonic()
token = direct._begin_acceptance_fence(root_task_id="direct-1", task_id="direct-1")["token"]
assert queue_mod.ACCEPTANCE_FENCES["direct-1"]["status"] == "active"
assert direct._inspect_acceptance_fence(token=token)["status"] == "active"
assert direct._end_acceptance_fence(token=token, outcome="terminal", expected_generation=0)["status"] == "sealed"
assert time.monotonic() - started < WAIT_SEC # nobody was asked, nobody was waited for
assert events.empty() and _ack_names(tmp_path) == []
# The other direction: a pooled worker still travels by event + ack.
pooled_events: stdqueue.Queue = stdqueue.Queue()
supervisor = _Supervisor(pooled_events, tmp_path)
try:
_pooled_agent(tmp_path, pooled_events, task_id="root-9")._begin_acceptance_fence(
root_task_id="root-9", task_id="root-9")
finally:
supervisor.stop()
assert [evt["type"] for evt in supervisor.seen] == ["acceptance_fence"]
def test_only_the_direct_chat_agent_is_built_with_the_in_process_transition(monkeypatch, tmp_path):
from ouroboros import agent as agent_module
from supervisor import queue, workers
built = SimpleNamespace()
monkeypatch.setattr(agent_module, "make_agent", lambda **_kwargs: built)
assert workers._get_chat_agent() is built
assert built.fence_transition is queue.transition_acceptance_fence
assert getattr(agent_module.OuroborosAgent, "fence_transition", None) is None # pooled default