"""Many workers on one Postgres store, killed and frozen at random. Root tasks run a scripted conversation (``noeta_cluster_scripted``): tool calls with a journalled side effect, background ``Bash`` jobs, sub-agents, ``ScheduleWakeup`` timers, and messages sent while they run. Worker processes (``noeta worker --lease 1``) are SIGKILLed and respawned, or SIGSTOPped past their lease and resumed. Afterwards: * every task finishes its script within the deadline; * no side-effect token is journalled twice, and a call the log shows finished was journalled exactly once; * every message sent is taken by its task exactly once; * every background job ends in the log as ended or lost, never missing, and the model is told so exactly once; * the log invariants of :mod:`tests.cluster.invariants` hold. Run with ``make cluster`` (``NOETA_CLUSTER_SEED`` replays a run; ``NOETA_CLUSTER_TASKS`` / ``NOETA_CLUSTER_SECONDS`` size it). A scenario a known multi-machine gap breaks is marked ``xfail(strict=True, raises=Gap)``: the gap's own check raises :class:`Gap`, any other broken invariant still fails the test, and fixing the gap turns the xfail into an error until the marker is removed. """ from __future__ import annotations import asyncio import os import random import re from pathlib import Path from typing import Any import pytest from noeta.core.events import TaskEnded, ToolFinished, ToolStarted from tests.cluster.harness import ( ROOT_AGENT, SPILL_AGENT, Cluster, Faults, Outcome, Spec, chaos, collect, kill_all, kill_job_worker, root_finished, seed_from_env, send_pings, wait_until, watch_rows, ) from tests.cluster.invariants import ( EpochWatch, jobs_of, last_reply, lost_jobs_ended_late, ) pytestmark = pytest.mark.cluster TASKS = int(os.environ.get("NOETA_CLUSTER_TASKS", "24")) SECONDS = float(os.environ.get("NOETA_CLUSTER_SECONDS", "20")) DRAIN = float(os.environ.get("NOETA_CLUSTER_DRAIN", "90")) LEASE = 1.0 #: Faults in the cron scenario last past the jobs' second firing. CRON_SECONDS = 70.0 class Gap(AssertionError): """A known multi-machine gap showed up: the expected failure of an xfail scenario.""" def fail(outcome: Outcome, problems: list[str]) -> str: known = {*outcome.roots.values(), *(r.task_id for r in outcome.rows)} ids = [w for p in problems for w in re.findall(r"[0-9a-f]{32}[\w.-]*", p) if w in known] ids += [outcome.roots[m] for p in problems for m in re.findall(r"root (r\d+)", p) if m in outcome.roots] head = "\n".join(problems[:30]) return f"{len(problems)} problem(s):\n{head}\n{outcome.describe(ids[:3])}" def make_specs(rng: random.Random, n: int, *, bg: bool) -> dict[str, Spec]: specs = {} for i in range(n): sid = f"r{i:02d}" spec = Spec(id=sid, effects=rng.randint(1, 3), agent=rng.random() < 0.5, wake=rng.random() < 0.5, lookup=round(rng.uniform(0.3, 1.0), 1) if rng.random() < 0.5 else 0.0) if bg and i % 2 == 0: spec.bg = round(rng.uniform(1.0, 3.0), 1) if bg and i % 4 == 0: # a job and nothing else: once the root waits, only the job's # end (or its loss) can wake it spec.effects, spec.agent, spec.wake, spec.bg, spec.lookup = 1, False, False, 4.0, 0.0 else: spec.pings = rng.choice([0, 0, 1, 2, 3]) specs[sid] = spec return specs #: ``--restart-limit`` for the random fault runs (see :func:`run_cluster`). CHAOS_RESTART_LIMIT = 50 async def run_cluster(tmp_path: Path, url: str, seed: int, specs: dict[str, Spec], *, seconds: float, targets: set[str] | None = None, workers: int = 3, drain: float = DRAIN, faulty: Any = chaos, fire_after: float = 2.0) -> Outcome: """Create the roots, run the faults and the pings for ``seconds``, then let the workers drain until every root is done or ``DRAIN`` passes.""" rng = random.Random(seed) # The random runs kill workers far more often than machines are lost, and # some faults aim at the one worker keeping a waiting root's claim, so a # healthy root can lose four workers in a row with no step between: a # high limit keeps the restart record in play without ending it. The # limit itself has its own scenario (the poison task below). cluster = Cluster(tmp_path, url, workers=workers, lease=LEASE, fire_after=fire_after, restart_limit=CHAOS_RESTART_LIMIT) faults, watch, stop = Faults(), EpochWatch(), asyncio.Event() await cluster.start() watcher = asyncio.create_task(watch_rows(cluster, watch, stop)) try: roots = {sid: await cluster.create(spec) for sid, spec in specs.items()} target_ids = {roots[s] for s in targets or ()} plan = [(rng.uniform(0, seconds), roots[sid], spec, j) for sid, spec in specs.items() for j in range(spec.pings)] if seconds > 0: await asyncio.gather(faulty(cluster, rng, seconds, faults, targets=target_ids), send_pings(cluster, rng, seconds, plan)) else: await send_pings(cluster, rng, 0, plan) for i, p in enumerate(cluster.procs): if p.poll() is not None: cluster.kill(i) # respawn anything that exited by itself async def all_done() -> bool: done = await asyncio.gather(*(root_finished(cluster, roots[s], spec) for s, spec in specs.items())) return all(done) await wait_until(all_done, drain, interval=1.0) stop.set() await watcher cluster.stop_workers() outcome = await collect(cluster, seed=seed, specs=specs, roots=roots, pings={s: sp.pings for s, sp in specs.items()}, watch=watch, faults=faults) finally: stop.set() await cluster.close() print(f"seed={seed} roots={len(specs)} tasks={len(outcome.rows)} kills={faults.kills} " f"stops={faults.stops} targeted={faults.targeted} " f"interrupted={outcome.interrupted_calls()} lost_jobs={outcome.lost_jobs()} " f"lookups_rerun={sum(s > 1 for s, _t in outcome.lookups().values())} " f"snapshots={outcome.snapshots} " f"journal={len(outcome.journal)} samples={watch.samples}") return outcome def assert_invariants(outcome: Outcome) -> None: """The invariants no known gap excuses.""" problems = (outcome.log_violations() + outcome.effect_violations() + outcome.lookup_violations() + outcome.watch.violations) assert not problems, fail(outcome, problems) def assert_all_done(outcome: Outcome, *, error: type[AssertionError] = AssertionError) -> None: problems = [p for sid in outcome.specs for p in outcome.root_done(sid)] if problems: raise error(fail(outcome, problems)) # ------------------------------------------------------------------ scenarios async def test_chaos_tools_agents_timers_messages(tmp_path: Path, pg_url: str) -> None: """Tool calls, sub-agents, timers and mid-run messages under random SIGKILL / SIGSTOP: everything finishes, nothing runs twice or is lost.""" seed = seed_from_env() specs = make_specs(random.Random(seed), TASKS, bg=False) outcome = await run_cluster(tmp_path, pg_url, seed, specs, seconds=SECONDS) assert outcome.faults.kills >= 3, fail(outcome, ["too few faults to mean anything"]) assert_invariants(outcome) assert_all_done(outcome) async def test_timers_and_sub_agents_outlive_every_worker(tmp_path: Path, pg_url: str) -> None: """Every worker is killed at once, again and again, while roots wait on wake-ups and sub-agents: a timer or a child never belongs to the process that set or started it, so every root still finishes.""" seed = seed_from_env() specs = {f"r{i:02d}": Spec(id=f"r{i:02d}", effects=1, agent=True, wake=True) for i in range(8)} outcome = await run_cluster(tmp_path, pg_url, seed, specs, seconds=12, faulty=kill_all) assert outcome.faults.kills >= 9, fail(outcome, ["too few faults to mean anything"]) assert_invariants(outcome) assert_all_done(outcome) async def test_cron_jobs_outlive_every_worker(tmp_path: Path, pg_url: str) -> None: """Recurring ``CronCreate`` jobs (every minute) under a minute of SIGKILL / SIGSTOP. The clock runs ten minutes behind, so a job's first firing is due at once; it then fires every real minute. Each root deletes its job after one or two firings: every job fires that often, is deleted, and never fires once its deletion is in the log (the one-firing roots are a minute past their deletion by the end).""" seed = seed_from_env() rng = random.Random(seed) specs = {f"r{i:02d}": Spec(id=f"r{i:02d}", effects=1, cron=[1, 2][i] if i < 2 else rng.choice([1, 2]), pings=rng.choice([0, 1])) for i in range(8)} outcome = await run_cluster(tmp_path, pg_url, seed, specs, seconds=CRON_SECONDS, fire_after=-540.0) assert outcome.faults.kills >= 3, fail(outcome, ["too few faults to mean anything"]) assert_invariants(outcome) assert_all_done(outcome) async def test_background_jobs_one_worker(tmp_path: Path, pg_url: str) -> None: """The background-job script itself, on one worker with no faults: every job ends, and its end reaches its root.""" seed = seed_from_env() specs = make_specs(random.Random(seed), 8, bg=True) outcome = await run_cluster(tmp_path, pg_url, seed, specs, seconds=0, workers=1) assert_invariants(outcome) assert_all_done(outcome) not_ended = [f"{tid}: job {job} {[k for _s, k in marks]}" for tid, records in outcome.logs.items() for job, marks in jobs_of(records).items() if [k for _s, k in marks] != ["started", "ended"]] assert not not_ended, fail(outcome, not_ended) async def test_background_job_end_taken_by_another_worker(tmp_path: Path, pg_url: str) -> None: """No faults, several workers: a job that finished is never called lost (its end is posted before its task's kept claim is given back).""" seed = seed_from_env() specs = {f"r{i:02d}": Spec(id=f"r{i:02d}", effects=1, bg=1.0) for i in range(16)} outcome = await run_cluster(tmp_path, pg_url, seed, specs, seconds=0, workers=5) assert_invariants(outcome) assert_all_done(outcome) assert outcome.lost_jobs() == 0, fail(outcome, ["a job that finished was called lost"]) async def test_chaos_with_background_jobs(tmp_path: Path, pg_url: str) -> None: """The chaos run with background jobs, some faults aimed at the worker keeping a waiting root's claim for its job: such a root still hears of its job (its lease runs out, and the next claimer tells it the job is lost).""" seed = seed_from_env() specs = make_specs(random.Random(seed), TASKS, bg=True) targets = {s for s, spec in specs.items() if spec.bg and not spec.pings and not spec.agent and not spec.wake} # Jobs run at most 4 s and wake-ups fire 2 s after they are set: what # has not finished 30 s after the faults stop never will. outcome = await run_cluster(tmp_path, pg_url, seed, specs, seconds=SECONDS, targets=targets, drain=30) assert_invariants(outcome) assert outcome.faults.targeted > 0, fail(outcome, ["no job worker was killed; rerun"]) assert_all_done(outcome) async def test_retry_safe_call_runs_again_after_its_worker_dies(tmp_path: Path, pg_url: str) -> None: """SIGKILL the worker in the middle of a ``retry_safe`` call: the call runs again elsewhere with the same idempotency key and finishes with its real result, not "outcome unknown".""" cluster = Cluster(tmp_path, pg_url, workers=3, lease=LEASE) await cluster.start() spec = Spec(id="r00", effects=1, lookup=4.0) faults = Faults() try: tid = await cluster.create(spec) async def looking() -> bool: row = await cluster.client.get(tid) records = await cluster.client.events(tid) return row.status == "running" and row.owner is not None and any( isinstance(r.event, ToolStarted) and r.event.call.name == "Lookup" for r in records) assert await wait_until(looking, 30), "the lookup never started" row = await cluster.client.get(tid) assert cluster.kill_pid(int(str(row.owner).split(":")[1])) faults.kills += 1 await wait_until(lambda: root_finished(cluster, tid, spec), 10 * LEASE + 8.0) outcome = await _outcome(cluster, tid, spec, faults) runs = Path(cluster.journal + ".lookups").read_text().splitlines() finally: await cluster.close() assert_invariants(outcome) assert_all_done(outcome) (starts, text), = outcome.lookups().values() assert (starts, text) == (2, "found r00-l"), fail(outcome, [f"{starts} starts: {text!r}"]) assert len(runs) == 2 and len({line.split()[1] for line in runs}) == 1, runs async def test_a_task_that_kills_every_worker_ends_after_the_restart_limit( tmp_path: Path, pg_url: str) -> None: """A healthy task that loses its worker once (mid-``Lookup``) still finishes; then a task whose pickup kills its worker every time ends ``failed`` with ``restart_limit`` at the fourth crash (three restarts). One after the other: run together, the poison's kills also hit the healthy task's worker at random, which made the run flaky on CI.""" cluster = Cluster(tmp_path, pg_url, workers=3, lease=LEASE) await cluster.start() spec = Spec(id="r00", effects=1, lookup=4.0) faults = Faults() stop = asyncio.Event() async def respawn() -> None: while not stop.is_set(): for i, p in enumerate(cluster.procs): if p.poll() is not None: cluster.kill(i) await asyncio.sleep(0.2) respawner = asyncio.create_task(respawn()) try: tid = await cluster.create(spec) async def looking() -> bool: row = await cluster.client.get(tid) records = await cluster.client.events(tid) return row.status == "running" and row.owner is not None and any( isinstance(r.event, ToolStarted) and r.event.call.name == "Lookup" for r in records) assert await wait_until(looking, 30), "the lookup never started" row = await cluster.client.get(tid) assert cluster.kill_pid(int(str(row.owner).split(":")[1])) faults.kills += 1 await wait_until(lambda: root_finished(cluster, tid, spec), 10 * LEASE + 8.0) poison = await cluster.client.create(ROOT_AGENT, "CLUSTER-POISON p1", config={"cwd": str(cluster.work)}) async def poison_ended() -> bool: return (await cluster.client.get(poison)).status == "ended" assert await wait_until(poison_ended, 30 * LEASE + 20.0), "the poisoned task runs on" ended = (await cluster.client.events(poison))[-1].event stop.set() await respawner outcome = await _outcome(cluster, tid, spec, faults) crashes = Path(cluster.arm + ".poison").read_text().splitlines() finally: stop.set() await respawner await cluster.close() assert_invariants(outcome) assert_all_done(outcome) assert isinstance(ended, TaskEnded) and ended.status == "failed", ended assert ended.error.startswith("restart_limit:"), ended.error assert len(crashes) == 4 and len({line.split()[0] for line in crashes}) == 4, crashes # ------------------------------------------------------------------ one gap each async def test_a_sub_agents_job_dies_with_it(tmp_path: Path, pg_url: str) -> None: """A sub-agent starts a background job and ends at once: the job dies with it (its side effect never lands), nothing tells the root of it, and the root finishes.""" cluster = Cluster(tmp_path, pg_url, workers=3, lease=LEASE) await cluster.start() spec = Spec(id="r00", effects=1, agent=True, child_bg=3.0) faults = Faults() try: tid = await cluster.create(spec) await wait_until(lambda: root_finished(cluster, tid, spec), 30) await asyncio.sleep(5.0) # past the job's end, had it lived outcome = await _outcome(cluster, tid, spec, faults) finally: await cluster.close() assert_invariants(outcome) assert_all_done(outcome) [child] = [r.task_id for r in outcome.rows if r.parent_id == tid] assert list(jobs_of(outcome.logs[child])), fail(outcome, ["the sub-agent started no job"]) assert not any(line.startswith("r00-cbg ") for line in outcome.journal), fail( outcome, ["the job of an ended sub-agent wrote"]) async def _one_job(tmp_path: Path, url: str, bg: float) -> tuple[Cluster, str, Spec, int]: """A cluster with one root whose background job is running while the root waits; returns the job's worker pid.""" cluster = Cluster(tmp_path, url, workers=3, lease=LEASE) await cluster.start() spec = Spec(id="r00", effects=1, bg=bg) tid = await cluster.create(spec) async def holding() -> bool: row = await cluster.client.get(tid) return row.status == "waiting" and bool(await cluster.running_job_pids()) assert await wait_until(holding, 30), "the job never started" (_root, pid), = await cluster.running_job_pids() return cluster, tid, spec, pid async def _outcome(cluster: Cluster, tid: str, spec: Spec, faults: Faults) -> Outcome: cluster.stop_workers() return await collect(cluster, seed=0, specs={spec.id: spec}, roots={spec.id: tid}, pings={spec.id: spec.pings}, watch=EpochWatch(), faults=faults) async def test_job_worker_killed_while_root_waits(tmp_path: Path, pg_url: str) -> None: """SIGKILL the worker keeping a waiting root's claim for its job, send nothing else: the root must still learn the job is lost and finish.""" cluster, tid, spec, _pid = await _one_job(tmp_path, pg_url, bg=6.0) faults = Faults() try: assert await kill_job_worker(cluster, {tid}, faults) await wait_until(lambda: root_finished(cluster, tid, spec), 10 * LEASE + 6.0) outcome = await _outcome(cluster, tid, spec, faults) finally: await cluster.close() assert_invariants(outcome) assert_all_done(outcome) assert outcome.lost_jobs() == 1, fail(outcome, ["the job was not recorded lost"]) async def test_killed_worker_job_stops_writing(tmp_path: Path, pg_url: str) -> None: """SIGKILL the job's worker, then a message reaches the root on another worker, which records the job lost. The job's process must be gone too: its side effect may not land after the log says lost.""" cluster, tid, spec, _pid = await _one_job(tmp_path, pg_url, bg=4.0) spec.pings = 1 faults = Faults() try: assert await kill_job_worker(cluster, {tid}, faults) await cluster.client.send(tid, "CLUSTER-PING r00 0", dedupe_key="ping-r00-0") await wait_until(lambda: root_finished(cluster, tid, spec), 10 * LEASE) await asyncio.sleep(5.0) # longer than the job had left to run outcome = await _outcome(cluster, tid, spec, faults) finally: await cluster.close() assert_invariants(outcome) assert_all_done(outcome) assert outcome.lost_jobs() == 1, fail(outcome, ["the job was not recorded lost"]) assert not any(line.startswith("r00-bg ") for line in outcome.journal), fail( outcome, ["the job of a dead worker wrote after the log said lost"]) async def test_frozen_worker_job_not_reported_twice(tmp_path: Path, pg_url: str) -> None: """SIGSTOP the job's worker past its lease, wake the root with a message (the job is recorded lost elsewhere), then SIGCONT: the job's end must not reach the task after the log called it lost.""" cluster, tid, spec, pid = await _one_job(tmp_path, pg_url, bg=4.0) spec.pings = 1 faults = Faults() try: i = cluster.index_of(pid) assert i is not None cluster.pause(i) faults.stops += 1 await asyncio.sleep(2 * LEASE + 1.0) # the lease runs out await cluster.client.send(tid, "CLUSTER-PING r00 0", dedupe_key="ping-r00-0") async def lost() -> bool: records = await cluster.client.events(tid) return any(k == "lost" for marks in jobs_of(records).values() for _s, k in marks) assert await wait_until(lost, 10 * LEASE), "no other worker recorded the job lost" cluster.resume(pid) await asyncio.sleep(6.0) # the job finishes and posts its end await wait_until(lambda: root_finished(cluster, tid, spec), 10 * LEASE) outcome = await _outcome(cluster, tid, spec, faults) finally: await cluster.close() assert_invariants(outcome) assert_all_done(outcome) late = lost_jobs_ended_late(outcome.logs[tid]) assert not late, fail(outcome, late) assert not any(line.startswith("r00-bg ") for line in outcome.journal), fail( outcome, ["the job of a frozen worker wrote after the log said lost"]) async def test_a_saved_output_is_read_back_on_another_worker(tmp_path: Path, pg_url: str) -> None: """A long ``Bash`` output is saved for the model; every worker is then killed, and the task's next turn, on a worker that never ran it, reads the saved file back through ``Read``, with no approval in default mode.""" cluster = Cluster(tmp_path, pg_url, workers=2, lease=LEASE) await cluster.start() try: tid = await cluster.client.create( SPILL_AGENT, 'CLUSTER-SPILL {"id": "s1", "lines": 4000}', config={"cwd": str(cluster.work)}) async def replied(prefix: str) -> bool: reply = last_reply(await cluster.client.events(tid)) or "" row = await cluster.client.get(tid) return reply.startswith(prefix) and row.inbox_read_seq == row.inbox_seq \ and row.status == "waiting" assert await wait_until(lambda: replied("SPILLED s1"), 60), \ await cluster.client.events(tid) ran_on = {p.pid for p in cluster.procs} for i in range(len(cluster.procs)): cluster.kill(i) assert not ran_on & {p.pid for p in cluster.procs} await cluster.client.send(tid, "CLUSTER-SPILL-READ s1") assert await wait_until(lambda: replied("SPILL-READ s1"), 60), \ await cluster.client.events(tid) records = await cluster.client.events(tid) assert last_reply(records) == "SPILL-READ s1 ok", "\n".join( str(r.event)[:400] for r in records if isinstance(r.event, ToolFinished)) saved = [r.event.result.text for r in records if isinstance(r.event, ToolFinished)] assert "Full output saved to: " + str(cluster.home / "tool-results" / tid) in saved[0] finally: await cluster.close()