Files
nextcraft/apps/ai-service/tests/telemetry/test_durability.py
T
CIAgent dc00a4f8f5 chore(P05): type & dependency hardening — mypy + recharts 3 (REQ-7-005)
Wave 5-1 mypy adoption (D-058 relaxed-first): mypy 2.3.1, [tool.mypy]
check_untyped_defs + no_implicit_optional + warn_unused_ignores +
warn_redundant_casts + warn_unreachable, no overrides (every dep ships
py.typed), files = ai_service + scripts/sandbox-agent.py + tests (not
excluded). 215 errors → 0: ~140 fixed at source incl. genuine bugs found
by the gate (LLMProvider protocol async-generator shape, SessionStore
protocol sync/async mismatch, structured_reply genericity, get_args
migrations, sandbox-agent Optional guard honesty, proctor test arg-order,
dead ExecResult kwarg, 16 sqlmodel col() sites); 34 live suppressions
scoped-coded + reasoned. Original 27 type:ignore dispositioned: 12 dead
removed, 15 scoped (warn_unused_ignores self-eliminates the rest).
Wired: scripts/typecheck.sh + package.json + turbo task + root
ai:typecheck — pnpm typecheck now 5 tsc + mypy.

Wave 5-2 recharts 2.15.4 → 3.10.1 (D-049/D-059): one breaking class
(Tooltip formatter ValueType typing); 2 one-line inference fixes on the
2 chart files; no rollback needed (well inside the G-106 timebox).
Deprecated direct dep resolved; uuid@9 gone from the tree. Build 248/248
with chart SSR markers verified in exported employer + dashboard pages.

Wave 5-3 deprecated-transitive audit (G-109): 4 lockfile entries remain,
all dev-only, none actionable within NFR budget — glob@7.2.3 + inflight +
rimraf@3 via Storybook docgen chain (flat-cache@3); glob@10.5.0 via
c8→test-exclude (D-052-binding coverage tool — swapping it is out of
budget). Python: no EOL packages; zero-warning regime under
filterwarnings=error is the machine-checked proof.

Counts: 670 py / 44 web / 64 cli, zero warnings, G-104 allowlist still
exactly 2.

---ci---
phase: 5
milestone: v0.7
status: execute
---/ci---
2026-09-27 10:08:07 +00:00

326 lines
15 KiB
Python

"""Dropped-connection durability probe (Task 2-4-01, REQ-3-003).
At-least-once delivery + server-side (learner,task,seq) dedup = every event
stored exactly once, in order — proven against a REAL uvicorn ingest and a
REAL namespace sandbox, with the connection severed mid-stream by a killable
TCP proxy (deterministic "network down" window).
Probe-guarded: skips (not fails) on hosts without user namespaces.
P2 (Task 2-1-04, deterministic waits) — per-site decisions:
* MOVED: the post-revive fixed settles (was ``asyncio.sleep(1.5)`` /
``asyncio.sleep(1.0)``) → ``_await_file_diffs`` deadline-bounded polls of
the durable store. A genuine hook exists: mid-outage file_diffs are only
durable after reconnect+flush, so their presence IS the condition the
sleeps settled blindly.
* STAYED: the 0.3s inter-write pacing inside the outage window (Phase B of
test_disconnect_reconnect_loses_nothing). By construction nothing is
observable while the link is severed — the pacing shapes the scenario
(distinct watcher poll ticks per write), it does not wait for a condition.
* NOT TOUCHED: test_sandbox_agent.py's poll loop (G-102 — already
event-driven, no fixed sleep exists there).
"""
from __future__ import annotations
import asyncio
import time
from pathlib import Path
import pytest
from ai_service.config import Settings
from ai_service.main import create_app
from ai_service.sandbox import SandboxManager
from ai_service.sandbox.unshare_backend import UnshareBackend
from ai_service.telemetry.ingest import TraceIntegrityMap
from ai_service.telemetry.store import SQLiteTraceStore
from ai_service.telemetry.tokens import IngestToken
from tests.helpers import AsyncKillableProxy, free_port, serve, skip_if_no_userns
# P1 tier marker (Task 1-1-02): both tests boot a REAL uvicorn ingest +
# REAL namespace sandboxes behind a killable TCP proxy — real_server
# tier, 180s timeout via the conftest collection hook.
pytestmark = [pytest.mark.real_server]
async def _await_events(
store: SQLiteTraceStore, learner: str, task: str, *, minimum: int, timeout_s: float = 30.0
) -> list:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
events = store.get_trace(learner, task)
if len(events) >= minimum:
return events
await asyncio.sleep(0.25)
return store.get_trace(learner, task)
async def _await_file_diffs(
store: SQLiteTraceStore,
learner: str,
task: str,
paths: set[str],
timeout_s: float = 30.0,
) -> None:
"""P2 (Task 2-1-04): poll the DURABLE store until every ``paths``
file_diff has landed — the event-driven replacement for the fixed
post-revive sleeps this file used to run.
Genuine hook: mid-outage writes are emitted while the connection is
severed, so their file_diff events can only become durable after the
agent reconnected AND flushed its spool — the exact condition the old
``asyncio.sleep(1.5)`` / ``asyncio.sleep(1.0)`` settled blindly. Raises on
deadline (the ``_wait_until`` model: fail at the wait site, not 30s later
in the final assertions).
"""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
stored = _stored_diff_paths(store, learner, task)
if paths <= stored:
return
await asyncio.sleep(0.25)
stored = _stored_diff_paths(store, learner, task)
assert paths <= stored, (
f"spool flush incomplete {timeout_s}s after revive; "
f"missing file_diffs: {sorted(paths - stored)}"
)
def _stored_diff_paths(store: SQLiteTraceStore, learner: str, task: str) -> set[str]:
paths: set[str] = set()
for e in store.get_trace(learner, task):
if e.kind != "file_diff":
continue
path = e.payload.get("path")
if isinstance(path, str): # P5: payload.get is object|None — filter to str
paths.add(path)
return paths
@pytest.mark.asyncio
async def test_disconnect_reconnect_loses_nothing(tmp_path: Path, sandbox_dir: Path) -> None:
"""Sever the agent's WS mid-stream; every event lands exactly once, ordered."""
skip_if_no_userns()
learner = "durability-learner"
task = "task-durability"
store = SQLiteTraceStore(db_path=tmp_path / "durability.db")
# Hermetic (v0.3.6): pin state dirs; sandbox workdirs go to the
# bind-mount-safe sandbox_dir fixture (overlayfs /tmp breaks userns binds).
app = create_app(
Settings(
provider="mock",
db_path=tmp_path / "durability-app.db",
sandbox_dir=sandbox_dir,
)
)
app.state.trace_store = store
app.state.trace_integrity = TraceIntegrityMap()
# v0.4.7: pre-set BEFORE the lifespan (state-injection) so the manager
# and the endpoint share one instance from the first dial.
app.state.ingest_token = IngestToken("durability-secret")
server_port = free_port()
proxy = AsyncKillableProxy(target_port=server_port)
await proxy.start()
manager = SandboxManager(
backend=UnshareBackend(),
settings=Settings(sandbox_dir=sandbox_dir),
ingest_token=app.state.ingest_token,
)
app.state.sandbox_manager = manager
def capture_env(sandbox_id: str, learner_id: str, task_id: str) -> dict[str, str]:
return {
"NC_LEARNER_ID": learner_id,
"NC_TASK_ID": task_id,
"NC_SANDBOX_ID": sandbox_id,
# Agent dials the PROXY; the proxy forwards to the real server.
# v0.4.7: URL carries the app's minted ingest token.
"NC_INGEST_URL": (
f"ws://127.0.0.1:{proxy.listen_port}/v1/telemetry/ingest"
f"?learner_id={learner_id}&task_id={task_id}&sandbox_id={sandbox_id}"
f"&token={app.state.ingest_token.mint(learner_id, task_id, sandbox_id)}"
),
# Fast reconnect so the probe stays bounded.
"NC_BACKOFF_BASE_S": "0.1",
"NC_BACKOFF_MAX_S": "0.5",
}
# P5 scoped: deliberate test seam — the proxy URL replaces the real
# capture agent's env (the durability probe dials the KillableProxy).
manager._capture_env = capture_env # type: ignore[method-assign]
try:
async with serve(app, "127.0.0.1", server_port):
handle = await manager.create(learner, task_id=task)
try:
live = manager._handles[handle.id]
backend = manager._backend
# Phase A — connected: a write streams through.
result = await backend.exec(live, ["sh", "-c", "echo one > a.txt"])
assert result.returncode == 0, result.stderr
events = await _await_events(store, learner, task, minimum=1)
assert events, "no events arrived before the outage"
# Phase B — network down: sever mid-stream, keep generating.
# The 0.3s pacing between writes is scenario shaping INSIDE a
# hard outage: by construction nothing is observable while the
# link is down (that is the point), and the spacing keeps the
# agent's watcher from coalescing the three files into one
# poll tick. P2 (Task 2-1-04): stays a fixed sleep — no
# condition hook exists inside a severed connection.
proxy.kill()
for n in ("two", "three", "four"):
result = await backend.exec(live, ["sh", "-c", f"echo {n} > {n}.txt"])
assert result.returncode == 0, result.stderr
await asyncio.sleep(0.3)
# Phase C — revive: the agent reconnects (fast backoff) and
# flushes the spool. Server dedups on (learner,task,seq).
# P2 (Task 2-1-04): the old fixed 1.5s settle is now a
# deadline-bounded poll of the DURABLE store — the Phase-B
# file_diffs are only stored after reconnect+flush, so their
# presence is the observable "spool flushed" condition.
proxy.revive()
await _await_file_diffs(store, learner, task, {"two.txt", "three.txt", "four.txt"})
for n in ("five", "six"):
result = await backend.exec(live, ["sh", "-c", f"echo {n} > {n}.txt"])
assert result.returncode == 0, result.stderr
events = await _await_events(
store, learner, task, minimum=6, timeout_s=30.0
)
seqs = [e.seq for e in events]
assert seqs == sorted(seqs), f"out of order after reconnect: {seqs}"
assert len(set(seqs)) == len(seqs), f"duplicates stored: {seqs}"
assert len(events) >= 6, f"lost events across the outage: {seqs}"
# Exactly-once storage despite at-least-once delivery: every spool
# flush may re-send the replay-margin line; dedup collapses it.
stored_files = {
e.payload.get("path") for e in events if e.kind == "file_diff"
}
assert stored_files, "file_diff events missing from the stored trace"
finally:
await manager.destroy(handle.id)
finally:
await proxy.stop() # P3: close relay/listener — no socket left to the GC
store.close()
@pytest.mark.asyncio
async def test_midburst_disconnect_loses_nothing(tmp_path: Path, sandbox_dir: Path) -> None:
"""REQ-5-007 / D-045 / MH-1d: sever MID-BURST — immediately after a rapid
multi-frame send, WITHOUT waiting for server observation of the burst —
then revive and require every emitted seq stored exactly once, in order.
This is the exact scenario the P07 de-flake documented as uncovered by the
one-line replay margin (kill-timing is racy, but the OUTCOME is invariant
under the seq-ack protocol: all interleavings converge to exactly-once
via spool replay + server dedup + ack-based trimming)."""
skip_if_no_userns()
learner = "midburst-learner"
task = "task-midburst"
store = SQLiteTraceStore(db_path=tmp_path / "midburst.db")
app = create_app(
Settings(provider="mock", db_path=tmp_path / "midburst-app.db", sandbox_dir=sandbox_dir)
)
app.state.trace_store = store
app.state.trace_integrity = TraceIntegrityMap()
# v0.4.7: pre-set BEFORE the lifespan (state-injection) so the manager
# and the endpoint share one instance from the first dial.
app.state.ingest_token = IngestToken("durability-secret")
server_port = free_port()
proxy = AsyncKillableProxy(target_port=server_port)
await proxy.start()
manager = SandboxManager(
backend=UnshareBackend(),
settings=Settings(sandbox_dir=sandbox_dir),
ingest_token=app.state.ingest_token,
)
app.state.sandbox_manager = manager
def capture_env(sandbox_id: str, learner_id: str, task_id: str) -> dict[str, str]:
return {
"NC_LEARNER_ID": learner_id,
"NC_TASK_ID": task_id,
"NC_SANDBOX_ID": sandbox_id,
# v0.4.7: URL carries the app's minted ingest token.
"NC_INGEST_URL": (
f"ws://127.0.0.1:{proxy.listen_port}/v1/telemetry/ingest"
f"?learner_id={learner_id}&task_id={task_id}&sandbox_id={sandbox_id}"
f"&token={app.state.ingest_token.mint(learner_id, task_id, sandbox_id)}"
),
"NC_BACKOFF_BASE_S": "0.1",
"NC_BACKOFF_MAX_S": "0.5",
}
# P5 scoped: deliberate test seam — the proxy URL replaces the real
# capture agent's env (the durability probe dials the KillableProxy).
manager._capture_env = capture_env # type: ignore[method-assign]
try:
async with serve(app, "127.0.0.1", server_port):
handle = await manager.create(learner, task_id=task)
try:
live = manager._handles[handle.id]
backend = manager._backend
# Phase A — connected: confirm the pipe works (1 event minimum).
result = await backend.exec(live, ["sh", "-c", "echo warm > warm.txt"])
assert result.returncode == 0, result.stderr
events = await _await_events(store, learner, task, minimum=1)
assert events, "warm-up event never arrived"
# Phase B — MID-BURST: rapid-fire writes with NO wait between
# them, then kill IMMEDIATELY (before any server observation).
# Frames are in TCP flight when the link dies — the one-line
# replay margin loses 1..N-1 of them pre-D-045.
proxy.kill()
burst = [f"burst-{n}" for n in range(10)]
for name in burst:
result = await backend.exec(live, ["sh", "-c", f"echo {name} > {name}.txt"])
assert result.returncode == 0, result.stderr
# Phase C — revive: agent reconnects (fast backoff), flushes the
# spool, acks trim it; server dedups any replayed margin.
# P2 (Task 2-1-04): the old fixed 1.0s settle is now a
# deadline-bounded poll of the DURABLE store — the mid-burst
# file_diffs were emitted into the severed link and only
# become durable after reconnect+flush, so their presence is
# the observable "spool flushed" condition.
proxy.revive()
await _await_file_diffs(
store, learner, task, {f"{name}.txt" for name in burst}
)
result = await backend.exec(live, ["sh", "-c", "echo after > after.txt"])
assert result.returncode == 0, result.stderr
events = await _await_events(store, learner, task, minimum=12, timeout_s=30.0)
seqs = [e.seq for e in events]
assert seqs == sorted(seqs), f"out of order after mid-burst reconnect: {seqs}"
assert len(set(seqs)) == len(seqs), f"duplicates stored: {seqs}"
# Contiguity: warm-up + 10 burst writes + watcher events + the
# after-write — no GAPS allowed (the ack protocol must deliver
# every in-flight frame via replay).
assert seqs == list(range(seqs[0], seqs[-1] + 1)), (
f"gaps in seq chain after mid-burst kill: {seqs}"
)
stored_files = {
e.payload.get("path") for e in events if e.kind == "file_diff"
}
assert "after.txt" in stored_files, "post-revive write missing from the trace"
finally:
await manager.destroy(handle.id)
finally:
await proxy.stop() # P3: close relay/listener — no socket left to the GC
store.close()