Implement worker coordination runtime and finish WP-0003
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07b5b-ea58-7ad2-bdbb-0b1c995cfc35
This commit is contained in:
parent
214964ccb8
commit
628f984a10
23 changed files with 3025 additions and 544 deletions
191
tests/test_adapters.py
Normal file
191
tests/test_adapters.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import json
|
||||
import socket
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from coordination_engine.adapters import Hub, SafetyError, Tamq, socket_request
|
||||
from coordination_engine.config import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def peer(tmp_path):
|
||||
path = tmp_path / "tamq.sock"
|
||||
server = socket.socket(socket.AF_UNIX)
|
||||
server.bind(str(path))
|
||||
path.chmod(0o600)
|
||||
server.listen()
|
||||
server.settimeout(0.05)
|
||||
state = {
|
||||
"requests": [],
|
||||
"messages": {},
|
||||
"endpoints": [{"endpoint_id": "tmux-amq-123", "repos": '["demo"]'}],
|
||||
"protocol": "0.1",
|
||||
"capabilities": ["bounded_delivery_ack_v1", "idempotent_send_v1"],
|
||||
}
|
||||
stop = threading.Event()
|
||||
|
||||
def run():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
conn, _ = server.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
with conn:
|
||||
request = json.loads(conn.makefile("rb").readline())
|
||||
state["requests"].append(request)
|
||||
op = request["op"]
|
||||
response = {"ok": True}
|
||||
if op == "ping":
|
||||
response.update(
|
||||
protocol=state["protocol"], capabilities=state["capabilities"]
|
||||
)
|
||||
elif op == "endpoints":
|
||||
response["endpoints"] = state["endpoints"]
|
||||
elif op == "send":
|
||||
key = request["idempotency_key"]
|
||||
state["messages"].setdefault(
|
||||
key, "message-" + str(len(state["messages"]))
|
||||
)
|
||||
response.update(message_id=state["messages"][key], state="pending")
|
||||
elif op == "message":
|
||||
response["message"] = {
|
||||
"state": "injected",
|
||||
"message_id": request["message_id"],
|
||||
}
|
||||
conn.sendall((json.dumps(response) + "\n").encode())
|
||||
|
||||
thread = threading.Thread(target=run)
|
||||
thread.start()
|
||||
yield path, state
|
||||
stop.set()
|
||||
thread.join(timeout=2)
|
||||
server.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(peer, monkeypatch):
|
||||
path, state = peer
|
||||
monkeypatch.setattr(
|
||||
"coordination_engine.adapters.registry",
|
||||
lambda: {"demo": "/demo", "coordination-engine": "/coordination"},
|
||||
)
|
||||
return Tamq(Config(tamq_socket=path).validate()), state
|
||||
|
||||
|
||||
def test_socket_protocol_and_stable_send(adapter):
|
||||
tamq, state = adapter
|
||||
lease = {"repo": "demo", "lease_id": "lease", "trigger_id": "trigger"}
|
||||
first = tamq.wake(lease, "Inspect work")
|
||||
second = tamq.wake(lease, "Inspect work")
|
||||
assert first["message_id"] == second["message_id"]
|
||||
assert len(state["messages"]) == 1
|
||||
send = next(r for r in state["requests"] if r["op"] == "send")
|
||||
assert send["metadata"] == {"lease_id": "lease", "trigger_id": "trigger"}
|
||||
assert send["endpoint_id"] == "tmux-amq-123"
|
||||
assert tamq.receipt(first["message_id"])["state"] == "injected"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("protocol", "1.0"),
|
||||
("capabilities", []),
|
||||
(
|
||||
"endpoints",
|
||||
[
|
||||
{"endpoint_id": "a", "repos": '["demo"]'},
|
||||
{"endpoint_id": "b", "repos": '["demo"]'},
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_protocol_and_ambiguity_gate_before_send(adapter, field, value):
|
||||
tamq, state = adapter
|
||||
state[field] = value
|
||||
with pytest.raises(SafetyError):
|
||||
tamq.wake({"repo": "demo", "lease_id": "l", "trigger_id": "t"}, "Inspect")
|
||||
assert not state["messages"]
|
||||
|
||||
|
||||
def test_registry_refresh_each_wake(adapter, monkeypatch):
|
||||
tamq, state = adapter
|
||||
monkeypatch.setattr("coordination_engine.adapters.registry", lambda: {})
|
||||
with pytest.raises(SafetyError):
|
||||
tamq.wake({"repo": "demo", "lease_id": "l", "trigger_id": "t"}, "Inspect")
|
||||
assert not state["requests"]
|
||||
|
||||
|
||||
def test_insecure_socket_rejected(peer):
|
||||
path, state = peer
|
||||
path.chmod(0o666)
|
||||
with pytest.raises(SafetyError):
|
||||
socket_request(path, {"op": "ping"})
|
||||
assert not state["requests"]
|
||||
|
||||
|
||||
def test_hub_http_snapshot_and_sanitized_projection():
|
||||
posts = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/repos/"):
|
||||
data = [{"id": "r", "slug": "demo"}]
|
||||
elif self.path == "/workplans/":
|
||||
data = [{"id": "p", "repo_id": "r", "status": "active"}]
|
||||
elif self.path.startswith("/tasks/"):
|
||||
data = [{"id": "t", "workplan_id": "p", "status": "todo"}]
|
||||
else:
|
||||
data = []
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data).encode())
|
||||
|
||||
def do_POST(self):
|
||||
posts.append(
|
||||
json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
)
|
||||
self.send_response(201)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"id":"receipt"}')
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever)
|
||||
thread.start()
|
||||
try:
|
||||
hub = Hub(
|
||||
Config(
|
||||
api_base=f"http://127.0.0.1:{server.server_port}", repos=["demo"]
|
||||
).validate()
|
||||
)
|
||||
snapshot = hub.snapshot()
|
||||
assert snapshot["repos"] == {"r": "demo"}
|
||||
assert snapshot["tasks"][0]["id"] == "t"
|
||||
hub.project({"id": "r1", "state": "offered", "reason": "wake_attempt"})
|
||||
assert posts[0]["detail"]["id"] == "r1"
|
||||
assert posts[0]["event_type"] == "coordination_receipt"
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("protocol", "0.invalid"),
|
||||
("capabilities", None),
|
||||
("endpoints", None),
|
||||
("endpoints", [{"endpoint_id": "e", "repos": None}]),
|
||||
],
|
||||
)
|
||||
def test_malformed_peer_stops_before_send(adapter, field, value):
|
||||
tamq, state = adapter
|
||||
state[field] = value
|
||||
with pytest.raises(SafetyError):
|
||||
tamq.wake({"repo": "demo", "lease_id": "l", "trigger_id": "t"}, "Inspect")
|
||||
assert not state["messages"]
|
||||
105
tests/test_cli.py
Normal file
105
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def cli(*args, env=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "coordination_engine.cli", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_env(tmp_path):
|
||||
env = dict(
|
||||
os.environ,
|
||||
PYTHONPATH=str(ROOT / "src"),
|
||||
COORDINATION_STATE_DIR=str(tmp_path / "state"),
|
||||
COORDINATION_SOCKET=str(tmp_path / "coord.sock"),
|
||||
COORDINATION_CONFIG=str(tmp_path / "config.toml"),
|
||||
)
|
||||
env.pop("STATEHUB_API_BASE", None)
|
||||
return env
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--help", "-h", "--version", "-V"])
|
||||
def test_help_version(flag, cli_env):
|
||||
result = cli(flag, env=cli_env)
|
||||
assert result.returncode == 0
|
||||
assert not Path(cli_env["COORDINATION_STATE_DIR"]).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", ["bash", "zsh", "fish"])
|
||||
def test_completion(shell, cli_env):
|
||||
result = cli("completion", shell, env=cli_env)
|
||||
assert result.returncode == 0
|
||||
assert "coordination-engine" in result.stdout
|
||||
|
||||
|
||||
def test_safe_default_refuses_unconfigured_service(cli_env):
|
||||
result = cli("once", env=cli_env)
|
||||
assert result.returncode == 1
|
||||
assert result.stderr
|
||||
|
||||
|
||||
def test_database_commands(cli_env):
|
||||
assert cli("db-version", env=cli_env).stdout.strip() == "1"
|
||||
result = cli("backup", env=cli_env)
|
||||
assert result.returncode == 0
|
||||
assert Path(result.stdout.strip()).exists()
|
||||
assert cli("history", env=cli_env).stdout.strip() == "[]"
|
||||
|
||||
|
||||
def test_service_lifecycle_and_restart(cli_env, tmp_path):
|
||||
gita = tmp_path / "gita"
|
||||
gita.write_text(
|
||||
'#!/bin/sh\nprintf "x,demo,/demo\\nx,coordination-engine,/coordination\\n"\n'
|
||||
)
|
||||
gita.chmod(0o700)
|
||||
cli_env["PATH"] = str(tmp_path) + os.pathsep + cli_env["PATH"]
|
||||
Path(cli_env["COORDINATION_CONFIG"]).write_text(
|
||||
'[coordination]\nrepos=["demo"]\napi_base="http://127.0.0.1:1"\ntimeout=0.1\n'
|
||||
)
|
||||
for stop in ("stop", "signal"):
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "coordination_engine.cli", "serve"],
|
||||
env=cli_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if Path(cli_env["COORDINATION_SOCKET"]).exists():
|
||||
break
|
||||
if proc.poll() is not None:
|
||||
pytest.fail(proc.communicate()[1])
|
||||
time.sleep(0.02)
|
||||
assert cli("ping", env=cli_env).returncode == 0
|
||||
result = cli("status", env=cli_env)
|
||||
assert json.loads(result.stdout)["schema_version"] == 1
|
||||
assert cli("serve", env=cli_env).returncode == 1
|
||||
if stop == "stop":
|
||||
assert cli("stop", env=cli_env).returncode == 0
|
||||
else:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
proc.communicate(timeout=5)
|
||||
assert proc.returncode == 0
|
||||
assert not Path(cli_env["COORDINATION_SOCKET"]).exists()
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
23
tests/test_live.py
Normal file
23
tests/test_live.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Opt-in read-only smoke; never injects a message or starts a worker."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from coordination_engine.adapters import Hub, Tamq, registry
|
||||
from coordination_engine.config import Config
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("COORDINATION_LIVE_SMOKE") != "1",
|
||||
reason="set COORDINATION_LIVE_SMOKE=1 explicitly",
|
||||
)
|
||||
def test_local_adapter_discovery():
|
||||
config = Config.load()
|
||||
assert "coordination-engine" in registry()
|
||||
assert isinstance(Hub(config).request("/repos/"), list)
|
||||
hello = Tamq(config).request("ping")
|
||||
assert {"bounded_delivery_ack_v1", "idempotent_send_v1"} <= set(
|
||||
hello["capabilities"]
|
||||
)
|
||||
351
tests/test_runtime.py
Normal file
351
tests/test_runtime.py
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import copy
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from coordination_engine.adapters import AdapterError, SafetyError
|
||||
from coordination_engine.config import Config
|
||||
from coordination_engine.runtime import Runtime
|
||||
from coordination_engine.store import Store
|
||||
|
||||
|
||||
class FakeHub:
|
||||
def __init__(self):
|
||||
self.data = {
|
||||
"repos": {"r": "demo"},
|
||||
"plans": [{"id": "p", "repo_id": "r", "status": "active"}],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "t",
|
||||
"workplan_id": "p",
|
||||
"status": "todo",
|
||||
"title": "Fix parser",
|
||||
"updated_at": "1",
|
||||
}
|
||||
],
|
||||
"dependencies": [],
|
||||
"messages": [],
|
||||
}
|
||||
self.receipts = []
|
||||
self.offline = False
|
||||
self.projection_offline = False
|
||||
|
||||
def snapshot(self):
|
||||
if self.offline:
|
||||
raise OSError("offline")
|
||||
return copy.deepcopy(self.data)
|
||||
|
||||
def project(self, receipt):
|
||||
if self.projection_offline:
|
||||
raise OSError("offline")
|
||||
self.receipts.append(receipt)
|
||||
|
||||
|
||||
class FakeTamq:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.messages = {}
|
||||
self.error = None
|
||||
self.state = "injected"
|
||||
|
||||
def wake(self, lease, prompt):
|
||||
self.calls.append((lease, prompt))
|
||||
if self.error:
|
||||
raise self.error
|
||||
self.messages.setdefault(lease["lease_id"], str(len(self.messages)))
|
||||
return {
|
||||
"endpoint_id": "e",
|
||||
"message_id": self.messages[lease["lease_id"]],
|
||||
"transport_state": self.state,
|
||||
}
|
||||
|
||||
def receipt(self, message_id):
|
||||
return {"state": self.state}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
config = Config(state_dir=tmp_path / "state", repos=["demo"]).validate()
|
||||
store = Store(config)
|
||||
hub, tamq = FakeHub(), FakeTamq()
|
||||
clock = [100.0]
|
||||
runtime = Runtime(config, store, hub, tamq, lambda: clock[0])
|
||||
yield config, store, hub, tamq, clock, runtime
|
||||
store.close()
|
||||
|
||||
|
||||
def test_dedupe_and_delivery_is_not_completion(env):
|
||||
_, store, hub, tamq, clock, runtime = env
|
||||
runtime.tick()
|
||||
clock[0] += 15
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 1
|
||||
assert store.leases()[0]["state"] == "offered"
|
||||
assert store.leases()[0]["transport_state"] == "injected"
|
||||
assert all("prompt" not in r and "checkpoint" not in r for r in hub.receipts)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("needs_human", True),
|
||||
("intervention_note", "private review note"),
|
||||
("title", "Get a secret"),
|
||||
("action_classes", ["external_publish"]),
|
||||
("blocking_reason", "operator decision"),
|
||||
],
|
||||
)
|
||||
def test_safety_stops_never_retry(env, field, value):
|
||||
_, store, hub, tamq, clock, runtime = env
|
||||
hub.data["tasks"][0][field] = value
|
||||
for i in range(8):
|
||||
clock[0] += 300
|
||||
runtime.tick()
|
||||
assert not tamq.calls
|
||||
assert store.leases()[0]["state"] == "stopped"
|
||||
assert value not in json.dumps(hub.receipts) if isinstance(value, str) else True
|
||||
|
||||
|
||||
def test_dependencies_change_without_task_revision(env):
|
||||
_, store, hub, tamq, clock, runtime = env
|
||||
hub.data["plans"].append({"id": "upstream", "repo_id": "other", "status": "active"})
|
||||
hub.data["dependencies"] = [
|
||||
{
|
||||
"id": "d",
|
||||
"from_workplan_id": "p",
|
||||
"to_workplan_id": "upstream",
|
||||
"relationship_type": "blocks",
|
||||
}
|
||||
]
|
||||
runtime.tick()
|
||||
assert not tamq.calls
|
||||
assert store.leases()[0]["state"] == "waiting"
|
||||
hub.data["plans"][1]["status"] = "finished"
|
||||
clock[0] += 15
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 1
|
||||
|
||||
|
||||
def test_unresolved_dependency_and_wait_task_do_not_dispatch(env):
|
||||
_, _, hub, tamq, _, runtime = env
|
||||
hub.data["tasks"][0]["status"] = "wait"
|
||||
runtime.tick()
|
||||
assert not tamq.calls
|
||||
|
||||
|
||||
def test_incoming_dependency_does_not_block(env):
|
||||
_, _, hub, tamq, _, runtime = env
|
||||
hub.data["dependencies"] = [
|
||||
{
|
||||
"id": "d",
|
||||
"from_workplan_id": "other",
|
||||
"to_workplan_id": "p",
|
||||
"relationship_type": "blocks",
|
||||
}
|
||||
]
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 1
|
||||
|
||||
|
||||
def test_retry_identity_and_exhaustion(env):
|
||||
config, store, _, tamq, clock, runtime = env
|
||||
tamq.error = AdapterError("offline")
|
||||
for i in range(7):
|
||||
clock[0] += 400
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == config.max_attempts
|
||||
assert len({row["lease_id"] for row, _ in tamq.calls}) == 1
|
||||
assert len({prompt for _, prompt in tamq.calls}) == 1
|
||||
assert store.leases()[0]["state"] == "stopped"
|
||||
|
||||
|
||||
def test_crash_after_send_recovers_same_message(env):
|
||||
config, store, hub, tamq, clock, runtime = env
|
||||
runtime.tick()
|
||||
recovered = Runtime(config, store, hub, tamq, lambda: clock[0])
|
||||
clock[0] += 31
|
||||
recovered.tick()
|
||||
clock[0] += 15
|
||||
recovered.tick()
|
||||
assert len(tamq.calls) == 2
|
||||
assert len(tamq.messages) == 1
|
||||
|
||||
|
||||
def test_worker_lifecycle_and_checkpoint_sanitization(env):
|
||||
_, store, hub, _, clock, runtime = env
|
||||
runtime.tick()
|
||||
row = store.leases()[0]
|
||||
with pytest.raises(ValueError):
|
||||
runtime.worker_update("renew", row["lease_id"], "demo")
|
||||
with pytest.raises(ValueError):
|
||||
runtime.worker_update("ack", row["lease_id"], "wrong")
|
||||
runtime.worker_update("ack", row["lease_id"], "demo")
|
||||
runtime.worker_update("renew", row["lease_id"], "demo")
|
||||
runtime.worker_update(
|
||||
"complete", row["lease_id"], "demo", {"summary": "private diagnostic detail"}
|
||||
)
|
||||
runtime.project()
|
||||
assert store.leases()[0]["state"] == "completed"
|
||||
assert "private diagnostic detail" not in json.dumps(hub.receipts)
|
||||
|
||||
|
||||
def test_expired_worker_cannot_renew(env):
|
||||
_, store, _, _, clock, runtime = env
|
||||
runtime.tick()
|
||||
clock[0] += 31
|
||||
with pytest.raises(ValueError):
|
||||
runtime.worker_update("ack", store.leases()[0]["lease_id"], "demo")
|
||||
|
||||
|
||||
def test_hub_outage_and_projection_recovery(env):
|
||||
_, store, hub, tamq, clock, runtime = env
|
||||
hub.projection_offline = True
|
||||
runtime.tick()
|
||||
assert store.pending_receipts()
|
||||
hub.offline = True
|
||||
clock[0] += 40
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 1
|
||||
hub.offline = hub.projection_offline = False
|
||||
clock[0] += 300
|
||||
runtime.tick()
|
||||
assert not store.pending_receipts()
|
||||
|
||||
|
||||
def test_one_active_worker_per_repo(env):
|
||||
_, store, hub, tamq, _, runtime = env
|
||||
hub.data["tasks"].append(dict(hub.data["tasks"][0], id="t2"))
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 1
|
||||
assert [r["state"] for r in store.leases()] == ["offered", "pending"]
|
||||
|
||||
|
||||
def test_explicit_action_message_only(env):
|
||||
_, _, hub, tamq, clock, runtime = env
|
||||
hub.data["tasks"] = []
|
||||
hub.data["messages"] = [
|
||||
{"id": "m", "to_agent": "demo", "subject": "FYI", "body": "ordinary text"}
|
||||
]
|
||||
runtime.tick()
|
||||
assert not tamq.calls
|
||||
hub.data["messages"][0]["subject"] = "[action] Inspect work"
|
||||
clock[0] += 15
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 1
|
||||
assert "ordinary text" not in tamq.calls[0][1]
|
||||
|
||||
|
||||
def test_adapter_safety_error_is_terminal(env):
|
||||
_, store, _, tamq, _, runtime = env
|
||||
tamq.error = SafetyError("ambiguous_endpoint")
|
||||
runtime.tick()
|
||||
assert store.leases()[0]["state"] == "stopped"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"max_attempts": 10},
|
||||
{"max_attempts": True},
|
||||
{"timeout": float("nan")},
|
||||
{"retry_backoff": []},
|
||||
{"policy_profile": "unsafe"},
|
||||
{"api_base": "http://user:secret@localhost"},
|
||||
{"allow": ["secrets"]},
|
||||
],
|
||||
)
|
||||
def test_invalid_config(kwargs):
|
||||
with pytest.raises(ValueError):
|
||||
Config(**kwargs).validate()
|
||||
|
||||
|
||||
def test_backup_schema_and_exclusive_writer(env):
|
||||
config, store, _, _, _, _ = env
|
||||
backup = store.backup()
|
||||
assert backup.stat().st_mode & 0o777 == 0o600
|
||||
with sqlite3.connect(backup) as db:
|
||||
assert db.execute("PRAGMA user_version").fetchone()[0] == 1
|
||||
other = Store(config)
|
||||
try:
|
||||
with store.writer_lock():
|
||||
with pytest.raises(BlockingIOError):
|
||||
with other.writer_lock():
|
||||
pass
|
||||
finally:
|
||||
other.close()
|
||||
store.db.execute("PRAGMA user_version=99")
|
||||
with pytest.raises(ValueError, match="newer"):
|
||||
Store(config)
|
||||
|
||||
|
||||
def test_checkpoint_continuation_gets_new_transport_identity(env):
|
||||
_, store, _, tamq, clock, runtime = env
|
||||
runtime.tick()
|
||||
row = store.leases()[0]
|
||||
runtime.worker_update("ack", row["lease_id"], "demo")
|
||||
runtime.worker_update(
|
||||
"checkpoint",
|
||||
row["lease_id"],
|
||||
"demo",
|
||||
{"summary": "Parser fixed", "next_action": "Run local checks"},
|
||||
)
|
||||
clock[0] += 15
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 2
|
||||
assert len(tamq.messages) == 2
|
||||
assert store.leases()[0]["state"] == "checkpointed"
|
||||
child = store.leases()[1]
|
||||
assert "using coordination-engine history" in tamq.calls[1][1]
|
||||
assert "State Hub checkpoint" not in tamq.calls[1][1]
|
||||
assert child["source"] == "checkpoint"
|
||||
assert child["source_id"] == row["trigger_id"]
|
||||
runtime.worker_update("ack", child["lease_id"], "demo")
|
||||
runtime.worker_update("complete", child["lease_id"], "demo")
|
||||
clock[0] += 15
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 2
|
||||
|
||||
|
||||
def test_revision_change_does_not_duplicate_active_worker(env):
|
||||
_, store, hub, tamq, clock, runtime = env
|
||||
runtime.tick()
|
||||
hub.data["tasks"][0]["status"] = "progress"
|
||||
hub.data["tasks"][0]["updated_at"] = "2"
|
||||
clock[0] += 15
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 1
|
||||
assert store.leases()[0]["state"] == "offered"
|
||||
|
||||
|
||||
def test_new_safety_gate_stops_old_revision_immediately(env):
|
||||
_, store, hub, tamq, clock, runtime = env
|
||||
runtime.tick()
|
||||
hub.data["tasks"][0]["needs_human"] = True
|
||||
clock[0] += 15
|
||||
runtime.tick()
|
||||
assert len(tamq.calls) == 1
|
||||
assert all(r["state"] == "stopped" for r in store.leases())
|
||||
|
||||
|
||||
def test_failed_transport_stops_coordination(env):
|
||||
_, store, _, tamq, clock, runtime = env
|
||||
runtime.tick()
|
||||
tamq.state = "failed"
|
||||
clock[0] += 15
|
||||
runtime.tick()
|
||||
assert store.leases()[0]["state"] == "stopped"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"checkpoint",
|
||||
[[], {"summary": {}}, {"files_changed": "file"}, {"blocked_reason": []}],
|
||||
)
|
||||
def test_malformed_checkpoint_does_not_release_lease(env, checkpoint):
|
||||
_, store, _, _, _, runtime = env
|
||||
runtime.tick()
|
||||
lease = store.leases()[0]["lease_id"]
|
||||
runtime.worker_update("ack", lease, "demo")
|
||||
with pytest.raises(ValueError):
|
||||
runtime.worker_update("checkpoint", lease, "demo", checkpoint)
|
||||
assert store.leases()[0]["state"] == "acknowledged"
|
||||
Loading…
Add table
Add a link
Reference in a new issue