feat(agent-call): implement remediation plan
This commit is contained in:
+84
-8
@@ -7,12 +7,15 @@ exists. Passing Mock probes never upgrades the result to production readiness.
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from types import TracebackType
|
||||
from typing import Any, Self
|
||||
|
||||
from agent_call.core import (
|
||||
AgentCallService,
|
||||
@@ -221,11 +224,14 @@ def probe_v04() -> dict[str, Any]:
|
||||
|
||||
|
||||
class ConflictContext:
|
||||
def __enter__(self) -> ConflictContext:
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> bool:
|
||||
if exc_type is not ConflictError:
|
||||
raise AssertionError("expected revision conflict")
|
||||
@@ -256,10 +262,37 @@ def probe_v06_v07() -> dict[str, Any]:
|
||||
"disconnect": "ari_disconnect",
|
||||
}
|
||||
outcomes: dict[str, str] = {}
|
||||
uncertain_before_reconcile: str | None = None
|
||||
for label, scenario in scenarios.items():
|
||||
body = command(f"cmd_{label}")
|
||||
svc.set_failure(body["command_id"], scenario)
|
||||
snapshot = run_success(svc, body)
|
||||
svc.publish_execute(body)
|
||||
if scenario == "ari_disconnect":
|
||||
call_id = None
|
||||
before = None
|
||||
for _ in range(500):
|
||||
svc.process_once()
|
||||
current = svc.get_command("tenant-demo", body["command_id"])
|
||||
call_id = current.get("call_id")
|
||||
if call_id:
|
||||
before = svc.get_call("tenant-demo", call_id)
|
||||
if before["call_state"] == "reconciling":
|
||||
break
|
||||
time.sleep(0.01)
|
||||
if not call_id or before is None:
|
||||
raise AssertionError("disconnect probe did not create a call")
|
||||
uncertain_before_reconcile = before["call_state"]
|
||||
if uncertain_before_reconcile != "reconciling":
|
||||
raise AssertionError(
|
||||
"disconnect probe did not retain unknown state"
|
||||
)
|
||||
svc.reconcile_call(
|
||||
call_id, ended=True, outcome="failed", reason="ARI_RECONCILED"
|
||||
)
|
||||
snapshot = svc.get_command("tenant-demo", body["command_id"])
|
||||
else:
|
||||
svc.wait_for_idle()
|
||||
snapshot = svc.get_command("tenant-demo", body["command_id"])
|
||||
call = svc.get_call("tenant-demo", snapshot["call_id"])
|
||||
outcomes[label] = call["outcome"]
|
||||
if len(call["attempts"]) != 1:
|
||||
@@ -280,6 +313,7 @@ def probe_v06_v07() -> dict[str, Any]:
|
||||
"asterisk_real": "not_run",
|
||||
"outcomes": outcomes,
|
||||
"fallback_redials": 0,
|
||||
"disconnect_state_before_reconcile": uncertain_before_reconcile,
|
||||
}
|
||||
|
||||
|
||||
@@ -374,9 +408,37 @@ def probe_v11_v12() -> dict[str, Any]:
|
||||
body = command("cmd_backup")
|
||||
run_success(svc, body)
|
||||
backup = Path(directory) / "backup.sqlite3"
|
||||
copy_database(str(Path(directory) / "executor.sqlite3"), str(backup))
|
||||
with redirect_stdout(io.StringIO()):
|
||||
copy_database(str(Path(directory) / "executor.sqlite3"), str(backup))
|
||||
if not backup.is_file():
|
||||
raise AssertionError("backup file was not created")
|
||||
restored = AgentCallService(
|
||||
db_path=backup,
|
||||
object_dir=Path(directory) / "restored-objects",
|
||||
broker=InMemoryBroker(),
|
||||
)
|
||||
try:
|
||||
restored_command = restored.get_command("tenant-demo", body["command_id"])
|
||||
restored_call = restored.get_call(
|
||||
"tenant-demo", restored_command["call_id"]
|
||||
)
|
||||
if (
|
||||
restored_command["status"] != "completed"
|
||||
or restored_call["recordings"][0]["status"] != "verified"
|
||||
):
|
||||
raise AssertionError("restored database lost the completed call facts")
|
||||
restored_counts = {
|
||||
"commands": restored.store.one("SELECT COUNT(*) AS n FROM commands")[
|
||||
"n"
|
||||
],
|
||||
"events": restored.store.one("SELECT COUNT(*) AS n FROM events")["n"],
|
||||
"inbox": restored.store.one("SELECT COUNT(*) AS n FROM inbox")["n"],
|
||||
"recordings": restored.store.one(
|
||||
"SELECT COUNT(*) AS n FROM recordings"
|
||||
)["n"],
|
||||
}
|
||||
finally:
|
||||
restored.stop()
|
||||
metrics = svc.metrics()
|
||||
if "agent_call_outbox_pending 0" not in metrics or 'mode="mock"' not in metrics:
|
||||
raise AssertionError("monitoring metrics lack healthy mock state")
|
||||
@@ -392,7 +454,9 @@ def probe_v11_v12() -> dict[str, Any]:
|
||||
return {
|
||||
"backup": "verified",
|
||||
"metrics": "healthy",
|
||||
"rollback_preserves_db": True,
|
||||
"rollback_preserves_db": "not_run",
|
||||
"restored_db_facts": True,
|
||||
"restored_counts": restored_counts,
|
||||
"real_mode_mock_rejection": True,
|
||||
}
|
||||
|
||||
@@ -419,10 +483,15 @@ def probe_v13() -> dict[str, Any]:
|
||||
if (
|
||||
evidence.get("status") != "observed"
|
||||
or evidence.get("production_claim", True)
|
||||
or not evidence.get("continuous_media_evidence", False)
|
||||
or load.get("minimum_answered_calls", 0) < 1000
|
||||
or load.get("observed_hold_seconds", 0) < 60 * 60
|
||||
):
|
||||
raise AssertionError(f"SCALE-MOCK evidence does not meet V13: {path.name}")
|
||||
return {
|
||||
"status": "not_run",
|
||||
"evidence_file": str(path.relative_to(ROOT)),
|
||||
"reason": "historical state-only evidence cannot satisfy continuous media V13",
|
||||
}
|
||||
return {
|
||||
"status": "observed",
|
||||
"evidence_file": str(path.relative_to(ROOT)),
|
||||
@@ -466,7 +535,14 @@ def run() -> dict[str, Any]:
|
||||
"duration_ms": round((time.monotonic() - started) * 1000, 3),
|
||||
"evidence": evidence,
|
||||
}
|
||||
except Exception as exc:
|
||||
except (
|
||||
AssertionError,
|
||||
KeyError,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
ServiceError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
results["probes"][name] = {
|
||||
"status": "FAIL",
|
||||
"duration_ms": round((time.monotonic() - started) * 1000, 3),
|
||||
|
||||
+31
-10
@@ -14,7 +14,13 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agent_call.core import AgentCallService, InMemoryBroker, iso, utcnow
|
||||
from agent_call.core import (
|
||||
AgentCallService,
|
||||
InMemoryBroker,
|
||||
ServiceError,
|
||||
iso,
|
||||
utcnow,
|
||||
)
|
||||
from scripts.acceptance import load_fixture
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -44,6 +50,8 @@ def build_profile(path: Path, duration_minutes: int) -> dict[str, Any]:
|
||||
{
|
||||
"cell_id": f"cell-{index:03d}",
|
||||
"capacity": 12,
|
||||
"media_capacity": 12,
|
||||
"ai_capacity": 12,
|
||||
"egress_pool_id": "egress-mock",
|
||||
"ari_mode": "mock",
|
||||
}
|
||||
@@ -53,10 +61,14 @@ def build_profile(path: Path, duration_minutes: int) -> dict[str, Any]:
|
||||
profile["limits"].update(
|
||||
{
|
||||
"global_concurrency": 1200,
|
||||
"global_cps": 1200,
|
||||
"global_cps": 20,
|
||||
"tenant_concurrency": 12,
|
||||
"tenant_cps": 12,
|
||||
"max_queue_messages": 2000,
|
||||
"tenant_cps": 1,
|
||||
"max_queue_messages": 1000,
|
||||
"max_queue_bytes": 16 * 1024 * 1024,
|
||||
"pending_window_per_tenant": 16,
|
||||
"pending_window_global": 64,
|
||||
"max_unacked_per_tenant": 4,
|
||||
# Keep calls alive through warm-up and the required observation window.
|
||||
"hold_ms": (startup_window_seconds + duration_minutes * 60 + 5) * 1000,
|
||||
}
|
||||
@@ -101,6 +113,9 @@ def dry_run(duration_minutes: int) -> dict[str, Any]:
|
||||
|
||||
|
||||
def cleanup_load(svc: AgentCallService) -> dict[str, Any]:
|
||||
# The failed-cell probe is restored before a full control barrier is applied;
|
||||
# a barrier must not claim every cell applied while one cell is unreachable.
|
||||
svc.set_cell_health("cell-000", True)
|
||||
requested = 0
|
||||
for tenant_index in range(100):
|
||||
tenant_id = f"tenant-{tenant_index:03d}"
|
||||
@@ -162,7 +177,7 @@ def cleanup_load(svc: AgentCallService) -> dict[str, Any]:
|
||||
def run(directory: Path, duration_minutes: int) -> dict[str, Any]:
|
||||
profile_path = directory / "profile.json"
|
||||
build_profile(profile_path, duration_minutes)
|
||||
broker = InMemoryBroker(max_messages=2000)
|
||||
broker = InMemoryBroker(max_messages=1000, max_bytes=16 * 1024 * 1024)
|
||||
svc = AgentCallService(
|
||||
db_path=directory / "scale.sqlite3",
|
||||
profile_path=profile_path,
|
||||
@@ -174,11 +189,17 @@ def run(directory: Path, duration_minutes: int) -> dict[str, Any]:
|
||||
template = load_fixture()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
for tenant_index in range(100):
|
||||
for offset in range(12):
|
||||
svc.publish_execute(
|
||||
command_for(template, tenant_index * 12 + offset, tenant_index)
|
||||
)
|
||||
for offset in range(12):
|
||||
for tenant_index in range(100):
|
||||
body = command_for(template, tenant_index * 12 + offset, tenant_index)
|
||||
while True:
|
||||
try:
|
||||
svc.publish_execute(body)
|
||||
break
|
||||
except ServiceError as exc:
|
||||
if exc.code != "PUBLISH_RATE_LIMIT":
|
||||
raise
|
||||
time.sleep(1.05)
|
||||
warmup_deadline = started + max(120, duration_minutes * 60)
|
||||
active = 0
|
||||
threshold_at = None
|
||||
|
||||
Reference in New Issue
Block a user