mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
205 lines
9.6 KiB
Python
205 lines
9.6 KiB
Python
"""Exercise actual server/bootstrap custody and byte identity without a browser install."""
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from threading import Barrier, Event, Thread
|
|
import time
|
|
from types import SimpleNamespace
|
|
import urllib.request
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
from tests import test_ui_smoke_playwright as ui
|
|
from tests.candidate_checkout import SENTINEL_ROUTE
|
|
|
|
pytestmark = [pytest.mark.serial, pytest.mark.ui_browser]
|
|
|
|
|
|
def _served(running, route: str) -> bytes:
|
|
with urllib.request.urlopen(running["url"] + route, timeout=5) as response: # noqa: S310
|
|
return response.read()
|
|
|
|
|
|
def _post(url: str, route: str, payload: dict) -> dict:
|
|
request = urllib.request.Request(url + route, data=json.dumps(payload).encode("utf-8"),
|
|
method="POST", headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310
|
|
return json.loads(response.read().decode("utf-8"))
|
|
|
|
|
|
@contextmanager
|
|
def _live_chat(url: str):
|
|
"""A WS client that keeps reading like the UI; an unread client stalls flow control."""
|
|
from websockets.exceptions import ConnectionClosed
|
|
from websockets.sync.client import connect
|
|
|
|
stopped, errors = Event(), []
|
|
with connect(url.replace("http://", "ws://", 1) + "/ws", open_timeout=30, proxy=None) as ws:
|
|
def receive():
|
|
while not stopped.is_set():
|
|
try:
|
|
ws.recv(timeout=1)
|
|
except TimeoutError:
|
|
continue
|
|
except ConnectionClosed as exc:
|
|
if not stopped.is_set():
|
|
errors.append(exc)
|
|
return
|
|
reader = Thread(target=receive, name="candidate-server-ws-reader")
|
|
reader.start()
|
|
try:
|
|
yield ws
|
|
finally:
|
|
stopped.set()
|
|
ws.close()
|
|
reader.join(5)
|
|
assert not reader.is_alive() and not errors, errors
|
|
|
|
|
|
def _wait_for(read, detail: str, timeout: float):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if (value := read()):
|
|
return value
|
|
time.sleep(0.5)
|
|
raise AssertionError(detail)
|
|
|
|
|
|
def _running_ids(data_dir: Path) -> set:
|
|
try:
|
|
snapshot = json.loads((data_dir / "state" / "queue_snapshot.json").read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return set()
|
|
return {str(row.get("id") or "") for row in snapshot.get("running") or [] if isinstance(row, dict)}
|
|
|
|
|
|
def _terminal_result(data_dir: Path, task_id: str):
|
|
path = data_dir / "task_results" / f"{task_id}.json"
|
|
try:
|
|
row = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return None
|
|
return row if row.get("status") in {"completed", "failed", "cancelled", "rejected_duplicate"} else None
|
|
|
|
|
|
def test_real_server_pool_runs_a_submitted_task_to_completion(tmp_path, monkeypatch):
|
|
"""Readiness is not execution: a worker of the real pool must complete a queued task.
|
|
|
|
`POST /api/tasks` admits a root task to the supervisor queue — unlike a chat
|
|
message, which is a direct turn outside the pool. Seeing it in the supervisor's
|
|
running set and then as a durable `completed` row with the model's answer proves
|
|
the served candidate's pool executes: the claim a green `supervisor_ready` once
|
|
made while every worker was dead. The streaming keyless stub replaces the UI
|
|
fixture's non-streaming mock, whose answers end as typed provider failures.
|
|
"""
|
|
from tests.system_e2e.harness import ScriptedStubModel, keyless_settings, write_settings_file
|
|
|
|
monkeypatch.setenv("OUROBOROS_RUN_UI_SMOKE", "1")
|
|
fixture = ui.direct_server_with_data.__wrapped__(tmp_path)
|
|
with ScriptedStubModel(final_answer="Pooled worker answer: OK.") as stub:
|
|
try:
|
|
running = next(fixture)
|
|
url, data_dir = running["url"], running["data_dir"]
|
|
# Seeded while no server runs; the next incarnation re-proves its origin.
|
|
running["stop_server"]()
|
|
write_settings_file(data_dir / "settings.json", keyless_settings(stub, OUROBOROS_MAX_WORKERS=1))
|
|
running["start_server"]()
|
|
with _live_chat(url): # An attached UI client, as in real use; it only drains frames.
|
|
task_id = str(_post(url, "/api/tasks", {
|
|
"description": f"Reply with OK. ({uuid.uuid4().hex[:8]})", "memory_mode": "forked",
|
|
"source": "cli", "metadata": {"source": "cli", "delegation_role": "root"},
|
|
}).get("task_id") or "")
|
|
assert task_id, "task creation returned no id"
|
|
seen_running = False
|
|
|
|
def terminal():
|
|
nonlocal seen_running
|
|
seen_running = seen_running or task_id in _running_ids(data_dir)
|
|
return _terminal_result(data_dir, task_id)
|
|
|
|
result = _wait_for(terminal, f"pooled task {task_id} never reached a durable terminal row", 180)
|
|
assert seen_running, "the task finished without ever being observed in the worker pool"
|
|
assert result["status"] == "completed", json.dumps(result)[:3000]
|
|
assert result["started_at"] and not result.get("_is_direct_chat")
|
|
assert "Pooled worker answer: OK." in str(result.get("result") or ""), result.get("result")
|
|
# Still the same served candidate after the pool executed.
|
|
assert json.loads(_served(running, "/api/health"))["runtime_version"] == running["candidate_version"]
|
|
finally:
|
|
fixture.close()
|
|
assert not (tmp_path / "repo").exists()
|
|
|
|
|
|
@pytest.mark.parametrize("launcher", ["direct", "settings"])
|
|
def test_real_server_uses_disposable_candidate_and_keeps_identity_through_restart(tmp_path, monkeypatch, launcher):
|
|
monkeypatch.setenv("OUROBOROS_RUN_UI_SMOKE", "1")
|
|
fixture = ui.direct_server_with_data.__wrapped__(tmp_path)
|
|
try:
|
|
if launcher == "settings":
|
|
from tests.test_settings_restart_browser import settings_server
|
|
|
|
request = SimpleNamespace(getfixturevalue=lambda name: next(fixture))
|
|
running = settings_server.__wrapped__(request, tmp_path, monkeypatch)
|
|
else:
|
|
running = next(fixture)
|
|
assert running["repo_dir"] != Path(ui.REPO_ROOT)
|
|
assert running["repo_dir"].is_relative_to(tmp_path)
|
|
assert len(running["candidate_identity"]) == 64
|
|
settings = (running["data_dir"] / "settings.json").read_text()
|
|
assert "ui-smoke-key" in settings
|
|
# The source VERSION never carries the suffix, so a server reporting it
|
|
# imported THIS checkout's ouroboros package, not the source or HEAD.
|
|
source_version = (Path(ui.REPO_ROOT) / "VERSION").read_text(encoding="utf-8").strip()
|
|
assert running["candidate_version"] != source_version
|
|
assert running["candidate_version"].startswith(source_version + "+candidate.")
|
|
assert _served(running, SENTINEL_ROUTE) == running["candidate_sentinel"]
|
|
assert json.loads(_served(running, "/api/health"))["runtime_version"] == running["candidate_version"]
|
|
running["restart_server"]()
|
|
# The restarted incarnation re-proves both origins (start_server asserts
|
|
# them too; this is the same claim read from outside the fixture).
|
|
assert _served(running, SENTINEL_ROUTE) == running["candidate_sentinel"]
|
|
assert json.loads(_served(running, "/api/health"))["runtime_version"] == running["candidate_version"]
|
|
assert os.environ["OUROBOROS_DATA_DIR"] != str(running["data_dir"])
|
|
finally:
|
|
fixture.close()
|
|
assert not (tmp_path / "repo").exists()
|
|
|
|
|
|
def test_concurrent_servers_keep_distinct_roots_and_owner_sentinels(tmp_path, monkeypatch):
|
|
owner = tmp_path / "owner"
|
|
for key, name in (("HOME", "home"), ("OUROBOROS_DATA_DIR", "data"),
|
|
("OUROBOROS_APP_ROOT", "app"), ("OUROBOROS_REPO_DIR", "repo"),
|
|
("OUROBOROS_SUBAGENT_PROJECTS_ROOT", "projects"),
|
|
("OUROBOROS_SUBAGENT_WORKTREE_ROOT", "worktrees"),
|
|
("OUROBOROS_DELIVERABLES_ROOT", "Deliverables")):
|
|
path = owner / name
|
|
path.mkdir(parents=True)
|
|
(path / "sentinel").write_bytes(b"owner state")
|
|
monkeypatch.setenv(key, str(path))
|
|
before = {path.relative_to(owner): path.read_bytes() for path in owner.rglob("sentinel")}
|
|
monkeypatch.setenv("OUROBOROS_RUN_UI_SMOKE", "1")
|
|
barrier = Barrier(2)
|
|
|
|
def run(number):
|
|
root = tmp_path / f"run-{number}"
|
|
fixture = ui.direct_server_with_data.__wrapped__(root)
|
|
try:
|
|
running = next(fixture)
|
|
assert running["data_dir"].is_relative_to(root)
|
|
assert running["repo_dir"].is_relative_to(root)
|
|
barrier.wait(timeout=90) # Both owned servers must be alive together.
|
|
assert _served(running, SENTINEL_ROUTE) == running["candidate_sentinel"]
|
|
return running["url"], running["candidate_identity"], running["candidate_sentinel"]
|
|
finally:
|
|
fixture.close()
|
|
assert not (root / "repo").exists()
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
first, second = executor.map(run, range(2))
|
|
assert first[0] != second[0]
|
|
assert first[1] == second[1], "the same source selection is one candidate identity"
|
|
assert first[2] != second[2], "each live server proved its OWN checkout, not a shared one"
|
|
assert {path.relative_to(owner): path.read_bytes()
|
|
for path in owner.rglob("*") if path.is_file()} == before
|