Some checks are pending
CI / lint-and-test (push) Waiting to run
* feat(kb): apply-worker to auto-land approved proposals (stage 2 automation) Event-driven worker that turns a HUMAN-approved kb_stage proposal into canonical state, so an approval in Telegram surfaces in Leo's identity without Leo applying its own work. - Fires only on status='approved' (never pending_review) -> proposer != applier holds; the human approval stays the trigger. No auto-approve anywhere. - Reuses scripts/apply_proposal.py verbatim as the sole apply path (same txn, rowcount=1 guard, FK stamp). Connects as the narrow kb_apply role, never superuser, never inside the hermes harness. - Render hook (--render-cmd / KB_APPLY_RENDER_CMD) is inert until the SOUL renderer (PR2) is deployed; applying still works, rendered SOUL just lags. - Ships INERT: report-only unless --enable / KB_APPLY_WORKER_ENABLED=1. systemd oneshot service + 5min timer, both shipped disabled. - 10 unit tests; candidate query validated read-only vs prod (0 applyable today). * fix(kb): apply-worker --max-per-tick cap + poison-pill retry ceiling Fixer draft-exit items: - --max-per-tick=1 (default): an enabled worker lands applies one-at-a-time and observably instead of draining the whole approved queue in one tick. - --max-attempts=3 ceiling with a persisted failure-count state file: a deterministically-failing approved proposal is treated as a poison pill and skipped after N consecutive failures, instead of retrying every tick forever. State persists on disk because the worker runs oneshot per timer tick. Both are inert until the worker is enabled; it still ships disabled.
199 lines
6.7 KiB
Python
199 lines
6.7 KiB
Python
"""Unit tests for scripts/apply_worker.py.
|
|
|
|
Pure tests: query building, candidate parsing, render-hook expansion, and the
|
|
report-only enablement gate -- no live database. Runnable under pytest or
|
|
standalone (`python3 tests/test_apply_worker.py`).
|
|
"""
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
try:
|
|
import pytest
|
|
except ImportError: # standalone fallback so the file runs without pytest installed
|
|
import contextlib
|
|
import types
|
|
|
|
pytest = types.SimpleNamespace()
|
|
|
|
@contextlib.contextmanager
|
|
def _raises(exc):
|
|
try:
|
|
yield
|
|
except exc:
|
|
return
|
|
raise AssertionError(f"expected {exc.__name__} to be raised")
|
|
|
|
pytest.raises = _raises
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
|
|
import apply_worker as w # noqa: E402
|
|
|
|
|
|
# --- candidate query ------------------------------------------------------ #
|
|
def test_candidate_query_filters_on_approved():
|
|
sql = w.build_candidate_query()
|
|
# The structural guarantee: worker can only ever act on approved proposals.
|
|
assert "status = 'approved'" in sql
|
|
# Never selects pending_review (no auto-approve possible).
|
|
assert "pending_review" not in sql
|
|
|
|
|
|
def test_candidate_query_requires_apply_payload():
|
|
sql = w.build_candidate_query()
|
|
assert "payload ? 'apply_payload'" in sql
|
|
|
|
|
|
def test_candidate_query_lists_worker_types_only():
|
|
sql = w.build_candidate_query()
|
|
for t in w.WORKER_TYPES:
|
|
assert f"'{t}'" in sql
|
|
# a non-applyable type must not appear
|
|
assert "reject_claim" not in sql
|
|
|
|
|
|
# --- candidate parsing ---------------------------------------------------- #
|
|
def test_parse_candidates_skips_blank_lines():
|
|
out = '{"id": "a", "proposal_type": "add_edge", "agent_id": null}\n\n'
|
|
rows = w.parse_candidates(out)
|
|
assert len(rows) == 1
|
|
assert rows[0]["id"] == "a"
|
|
|
|
|
|
# --- render hook ---------------------------------------------------------- #
|
|
def test_render_command_none_when_unset():
|
|
assert w.build_render_command("", "agent-1") is None
|
|
|
|
|
|
def test_render_command_none_without_agent():
|
|
assert w.build_render_command("python3 render_soul.py --agent-id {agent_id}", None) is None
|
|
|
|
|
|
def test_render_command_expands_agent_id():
|
|
cmd = w.build_render_command("python3 render_soul.py --agent-id {agent_id}", "abc")
|
|
assert cmd == ["python3", "render_soul.py", "--agent-id", "abc"]
|
|
|
|
|
|
# --- enablement gate ------------------------------------------------------ #
|
|
def _args(**over):
|
|
base = dict(
|
|
enable=False, limit=20, max_per_tick=1, max_attempts=3,
|
|
failure_state_file=None, applied_by="kb-apply",
|
|
apply_script="apply_proposal.py", render_cmd="",
|
|
secrets_file="x", container="teleo-pg", db="teleo",
|
|
host="127.0.0.1", role="kb_apply",
|
|
)
|
|
base.update(over)
|
|
return argparse.Namespace(**base)
|
|
|
|
|
|
def test_report_only_gate_does_not_apply(monkeypatch=None):
|
|
calls = []
|
|
w.ap.load_password = lambda _f: "pw"
|
|
w.fetch_candidates = lambda a, p: [{"id": "p1", "proposal_type": "revise_strategy", "agent_id": "ag"}]
|
|
w.apply_one = lambda a, pid: calls.append(pid)
|
|
|
|
rc = w.run(_args(enable=False))
|
|
assert rc == 0
|
|
assert calls == [] # disabled worker performs NO writes
|
|
|
|
|
|
def test_enabled_worker_applies_and_renders():
|
|
applied, rendered = [], []
|
|
w.ap.load_password = lambda _f: "pw"
|
|
w.fetch_candidates = lambda a, p: [{"id": "p1", "proposal_type": "revise_strategy", "agent_id": "ag"}]
|
|
w.apply_one = lambda a, pid: applied.append(pid)
|
|
w.render_one = lambda a, agent_id: rendered.append(agent_id) or "ok"
|
|
|
|
rc = w.run(_args(enable=True))
|
|
assert rc == 0
|
|
assert applied == ["p1"]
|
|
assert rendered == ["ag"] # revise_strategy triggers a render
|
|
|
|
|
|
def test_enabled_worker_skips_render_for_add_edge():
|
|
applied, rendered = [], []
|
|
w.ap.load_password = lambda _f: "pw"
|
|
w.fetch_candidates = lambda a, p: [{"id": "e1", "proposal_type": "add_edge", "agent_id": None}]
|
|
w.apply_one = lambda a, pid: applied.append(pid)
|
|
w.render_one = lambda a, agent_id: rendered.append(agent_id) or "ok"
|
|
|
|
rc = w.run(_args(enable=True))
|
|
assert rc == 0
|
|
assert applied == ["e1"]
|
|
assert rendered == [] # add_edge changes the graph, not the rendered self
|
|
|
|
|
|
# --- per-tick cap + poison-pill ceiling ----------------------------------- #
|
|
def _cands(*ids):
|
|
return [{"id": i, "proposal_type": "add_edge", "agent_id": None} for i in ids]
|
|
|
|
|
|
def test_partition_caps_to_max_per_tick():
|
|
split = w.partition_candidates(_cands("a", "b", "c"), {}, max_attempts=3, max_per_tick=1)
|
|
assert [c["id"] for c in split["to_apply"]] == ["a"] # only 1 applied per tick
|
|
assert split["poisoned"] == []
|
|
|
|
|
|
def test_partition_skips_poison_pill_over_ceiling():
|
|
split = w.partition_candidates(_cands("a", "b"), {"a": 3}, max_attempts=3, max_per_tick=5)
|
|
assert [c["id"] for c in split["poisoned"]] == ["a"] # a is poisoned, never retried
|
|
assert [c["id"] for c in split["to_apply"]] == ["b"] # b still eligible
|
|
|
|
|
|
def test_enabled_worker_caps_applies_at_one_per_tick():
|
|
applied = []
|
|
w.ap.load_password = lambda _f: "pw"
|
|
w.fetch_candidates = lambda a, p: _cands("e1", "e2", "e3")
|
|
w.apply_one = lambda a, pid: applied.append(pid)
|
|
|
|
rc = w.run(_args(enable=True, max_per_tick=1))
|
|
assert rc == 0
|
|
assert applied == ["e1"] # capped to 1 even with 3 approved candidates
|
|
|
|
|
|
def test_failure_state_roundtrip_and_ceiling(tmp_path=None):
|
|
import tempfile
|
|
|
|
d = tempfile.mkdtemp()
|
|
path = str(Path(d) / "failures.json")
|
|
w.save_failure_state(path, {"p1": 2})
|
|
assert w.load_failure_state(path) == {"p1": 2}
|
|
# p1 at 2 with max_attempts 3 is still eligible; at 3 it poisons
|
|
split = w.partition_candidates(_cands("p1"), {"p1": 3}, max_attempts=3, max_per_tick=1)
|
|
assert [c["id"] for c in split["poisoned"]] == ["p1"]
|
|
|
|
|
|
def test_apply_failure_increments_persisted_count():
|
|
import tempfile
|
|
|
|
path = str(Path(tempfile.mkdtemp()) / "failures.json")
|
|
w.ap.load_password = lambda _f: "pw"
|
|
w.fetch_candidates = lambda a, p: _cands("bad")
|
|
|
|
def _boom(a, pid):
|
|
raise RuntimeError("deterministic failure")
|
|
|
|
w.apply_one = _boom
|
|
rc = w.run(_args(enable=True, failure_state_file=path, max_per_tick=1))
|
|
assert rc == 1
|
|
assert w.load_failure_state(path).get("bad") == 1 # count persisted for next tick
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import traceback
|
|
|
|
failures = 0
|
|
for name, fn in sorted(globals().items()):
|
|
if name.startswith("test_") and callable(fn):
|
|
try:
|
|
fn()
|
|
print(f"PASS {name}")
|
|
except Exception: # noqa: BLE001
|
|
failures += 1
|
|
print(f"FAIL {name}")
|
|
traceback.print_exc()
|
|
sys.exit(1 if failures else 0)
|