2026-08-24 00:21:51 -03:00
|
|
|
"""HUX-05 run budgets and cancellation receipts.
|
|
|
|
|
|
|
|
|
|
Security obligations exercised: SO-40 (budget state per run against the
|
|
|
|
|
effective policy; crossing a limit emits ``budget.exhausted``), SO-41 (a
|
|
|
|
|
receipt says ``cancelled`` only when the process registry is empty and
|
|
|
|
|
carries every side effect the hook reports; a second stop returns the same
|
|
|
|
|
receipt).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
|
|
|
|
if str(FOUNDATION) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(FOUNDATION))
|
|
|
|
|
|
|
|
|
|
from hux import audit, contracts, identity, store # noqa: E402
|
|
|
|
|
from hux import events as hux_events # noqa: E402
|
|
|
|
|
from hux.server import build_router # noqa: E402
|
|
|
|
|
|
|
|
|
|
SCHEMAS = contracts.load_all()
|
2026-08-24 01:24:14 -03:00
|
|
|
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"}
|
2026-08-24 00:21:51 -03:00
|
|
|
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
|
|
|
|
|
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
2026-08-24 01:58:14 -03:00
|
|
|
OTHER_WORKER = {**WORKER, "X-Hux-Subject": OTHER["X-Hux-Subject"]}
|
2026-08-24 00:21:51 -03:00
|
|
|
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
|
|
|
|
CONV = "conv_0001abcd"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def router(tmp_path):
|
2026-08-24 01:24:14 -03:00
|
|
|
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk"})
|
2026-08-24 00:21:51 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def events(monkeypatch):
|
|
|
|
|
seen: list[dict] = []
|
|
|
|
|
monkeypatch.setattr(hux_events, "emit", lambda store, identity, conversation_id, kind, summary, **extra: seen.append({"conversation_id": conversation_id, "kind": kind, "summary": summary, **extra}))
|
|
|
|
|
return seen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def call(router, method, path, body=None, headers=WORKER):
|
|
|
|
|
raw = b"" if body is None else json.dumps(body).encode()
|
|
|
|
|
response = router.dispatch(method, path, headers, raw)
|
|
|
|
|
return response.status, response.body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def valid(record):
|
|
|
|
|
problems = contracts.validate_record(record, SCHEMAS)
|
|
|
|
|
assert problems == [], problems
|
|
|
|
|
return record
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- budgets ------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_budget_starts_empty_against_the_effective_policy(router):
|
|
|
|
|
status, body = call(router, "GET", "/hux/v1/runs/run_9f/budget")
|
|
|
|
|
assert status == 200 and valid(body)["run_id"] == "run_9f"
|
|
|
|
|
assert body["spent"] == {k: 0 for k in ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")}
|
|
|
|
|
assert body["limits"]["tokens_per_run"] == 200000 and body["exhausted"] == []
|
|
|
|
|
assert "revision" not in body and not any(k.startswith("_") for k in body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_increments_accumulate_and_exhaust_each_limit(router, events):
|
|
|
|
|
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe",
|
|
|
|
|
"budgets": {"tokens_per_run": 100, "tool_calls_per_run": 3, "subagents_per_run": 0, "scope": {"paths": ["/work"]}}}, HEADERS)
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tokens": 60, "tool_calls": 1})
|
|
|
|
|
assert status == 200 and valid(body)["spent"]["tokens"] == 60
|
|
|
|
|
assert body["exhausted"] == ["subagents_per_run"], "a zero limit is exhausted before any spend"
|
|
|
|
|
assert body["limits"] == {"tokens_per_run": 100, "tool_calls_per_run": 3, "subagents_per_run": 0}, "scope is policy detail, not a limit"
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"tokens": 40, "tool_calls": 2, "delegations": 5})
|
|
|
|
|
assert body["spent"] == {"tokens": 100, "tool_calls": 3, "wall_clock_seconds": 0, "delegations": 5, "spend_units": 0, "subagents": 0}
|
|
|
|
|
assert body["exhausted"] == ["tokens_per_run", "tool_calls_per_run", "subagents_per_run"]
|
|
|
|
|
assert [e["kind"] for e in events] == ["budget.exhausted", "budget.exhausted"]
|
|
|
|
|
assert events[1]["summary"] == "Budget exhausted: tokens_per_run, tool_calls_per_run" and events[1]["run_id"] == "run_9f"
|
|
|
|
|
assert events[1]["conversation_id"] == CONV, "the conversation learned on the first report sticks"
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"tokens": 1})
|
|
|
|
|
assert status == 200 and len(events) == 2, "already exhausted limits do not re-emit"
|
|
|
|
|
assert call(router, "GET", "/hux/v1/runs/run_9f/budget")[1] == body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_budget_follows_the_conversation_policy(router):
|
|
|
|
|
_, conversation = call(router, "POST", "/hux/v1/conversations", {"title": "Budgeted"}, HEADERS)
|
|
|
|
|
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": conversation["id"]}, "autonomy": "safe", "budgets": {"spend_units": 1}}, HEADERS)
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_c/budget", {"conversation_id": conversation["id"], "spend_units": 1})
|
|
|
|
|
assert body["limits"] == {"spend_units": 1} and body["exhausted"] == ["spend_units"]
|
|
|
|
|
assert call(router, "GET", "/hux/v1/runs/run_c/budget")[1]["limits"] == {"spend_units": 1}
|
|
|
|
|
assert call(router, "GET", "/hux/v1/runs/run_c/budget", headers=OTHER)[1]["spent"]["spend_units"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("body", [[], {"tokens": -1}, {"tokens": True}, {"tokens": "5"}, {"conversation_id": "x"}])
|
|
|
|
|
def test_budget_rejects_bad_bodies(router, body):
|
|
|
|
|
status, error = call(router, "POST", "/hux/v1/runs/run_9f/budget", body)
|
|
|
|
|
assert status == 400 and valid(error)["code"] == "invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- stop receipts ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_path):
|
|
|
|
|
side_effects = [{"description": "Partial file left in workspace", "reverted": True, "evidence": {"kind": "file", "id": "notes.md"}}]
|
2026-08-24 00:45:13 -03:00
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": side_effects, "conversation_id": CONV})
|
2026-08-24 00:21:51 -03:00
|
|
|
assert status == 201 and valid(body)["outcome"] == "cancelled"
|
2026-08-24 00:45:13 -03:00
|
|
|
assert body["requested_by"] == {"type": "system", "id": "worker"}
|
2026-08-24 00:21:51 -03:00
|
|
|
assert body["requested_at"] == body["acknowledged_at"] == body["completed_at"]
|
|
|
|
|
assert body["side_effects"] == side_effects and body["conversation_id"] == CONV and "revision" not in body
|
|
|
|
|
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
|
|
|
|
|
assert (status, again) == (200, body)
|
|
|
|
|
assert [e["kind"] for e in events] == ["run.cancelled"]
|
|
|
|
|
assert events[0]["evidence"] == [{"kind": "run", "id": body["id"]}] and events[0]["run_id"] == "run_9f"
|
2026-08-24 01:24:14 -03:00
|
|
|
rows = [(r["action"], r.get("reason")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))) if r["action"] == "runs.stop"]
|
2026-08-24 00:21:51 -03:00
|
|
|
assert rows == [("runs.stop", "cancelled"), ("runs.stop", "replayed")]
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 00:45:13 -03:00
|
|
|
def test_only_the_gateway_may_vouch_for_an_empty_registry_and_a_failed_receipt_can_be_superseded(router, events, tmp_path):
|
2026-08-24 01:58:14 -03:00
|
|
|
"""F8 / SO-41: browser callers cannot write receipts; only the Worker records the proven outcome."""
|
2026-08-24 00:45:13 -03:00
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "conversation_id": CONV}, HEADERS)
|
2026-08-24 01:58:14 -03:00
|
|
|
assert (status, body["code"]) == (403, "forbidden")
|
2026-08-24 00:45:13 -03:00
|
|
|
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True}, HEADERS)
|
2026-08-24 01:58:14 -03:00
|
|
|
assert (status, again["code"]) == (403, "forbidden")
|
2026-08-24 00:45:13 -03:00
|
|
|
status, done = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": [{"description": "x", "reverted": True}]})
|
2026-08-24 01:58:14 -03:00
|
|
|
assert status == 201 and valid(done)["outcome"] == "cancelled" and "completed_at" in done
|
|
|
|
|
assert "conversation_id" not in done and done["requested_by"] == {"type": "system", "id": "worker"}
|
2026-08-24 01:24:14 -03:00
|
|
|
tenant = store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))
|
2026-08-24 00:45:13 -03:00
|
|
|
stored = tenant.get("receipts", done["id"])
|
2026-08-24 01:58:14 -03:00
|
|
|
assert stored["revision"] == 1 and contracts.validate_record(stored, SCHEMAS) == [], "stored receipts carry revision (F11)"
|
2026-08-24 00:45:13 -03:00
|
|
|
status, third = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
|
|
|
|
|
assert (status, third) == (200, done), "a terminal receipt never changes again"
|
|
|
|
|
rows = [r.get("reason") for r in audit.recent(tenant) if r["action"] == "runs.stop"]
|
2026-08-24 01:58:14 -03:00
|
|
|
assert rows == ["stop receipts only from worker trust", "stop receipts only from worker trust", "cancelled", "replayed"]
|
|
|
|
|
assert events == [], "a receipt without an authoritative conversation binding emits no conversation event"
|
2026-08-24 00:45:13 -03:00
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_h/stop", {"already_complete": True}, HEADERS)
|
2026-08-24 01:58:14 -03:00
|
|
|
assert (status, body["code"]) == (403, "forbidden")
|
2026-08-24 00:45:13 -03:00
|
|
|
|
|
|
|
|
|
2026-08-24 00:21:51 -03:00
|
|
|
def test_stop_outcomes_follow_the_process_registry(router):
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_a/stop", {"process_registry_empty": False, "side_effects": []})
|
|
|
|
|
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body
|
|
|
|
|
assert body["requested_by"] == {"type": "system", "id": "worker"}
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_b/stop", {"already_complete": True})
|
|
|
|
|
assert valid(body)["outcome"] == "already_complete" and "completed_at" in body
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_c/stop", {})
|
|
|
|
|
assert valid(body)["outcome"] == "failed_to_cancel", "no registry report means the stop is not proven"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("body", [[], {"side_effects": "none"}, {"side_effects": [{"description": ""}]}, {"conversation_id": "bad"}])
|
|
|
|
|
def test_stop_rejects_bad_bodies(router, body):
|
|
|
|
|
status, error = call(router, "POST", "/hux/v1/runs/run_9f/stop", body)
|
|
|
|
|
assert status == 400 and valid(error)["code"] == "invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_receipts_are_per_tenant(router):
|
|
|
|
|
call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True})
|
2026-08-24 01:58:14 -03:00
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False}, OTHER_WORKER)
|
2026-08-24 00:21:51 -03:00
|
|
|
assert status == 201 and body["outcome"] == "failed_to_cancel", "same run id, other subject, its own receipt"
|