File size: 27,712 Bytes
f76c374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
#!/usr/bin/env python3
"""iso-certify β€” AC-4 dashboard turn-isolation certify harness.

Certifies Mechanism-B process isolation
(``docs/desktop/2026-07-04-dashboard-process-isolation-PRD.md``, AC-4): under
6 concurrent heavy agent turns, the dashboard's HTTP/ws SERVING plane must stay
responsive (p99 < 1s) with zero event-loop stalls.

What it does
------------
1. Spawns a SCRATCH dashboard (``hermes dashboard``) bound to loopback on a
   free port, with an ISOLATED ``HERMES_HOME`` (temp dir, minimal seeded state).
   It NEVER touches the live :9119 dashboard / ai.hermes.dashboard / live
   state.db. Loopback bind β‡’ no auth gate (web_server.should_require_auth).
2. Arms the synthetic GIL-heavy turn seam (``HERMES_ISO_CERTIFY_SYNTH_TURN=1``,
   see ``tui_gateway/synthetic_turn.py``) so 6 concurrent turns reproduce the
   ``take_gil`` interpreter-contention regime WITHOUT real model calls. A
   network/sleep stub would release the GIL and NOT reproduce the incident, so
   it would be a fake green β€” the synthetic turn is pure-Python CPU on purpose.
3. Drives 6 concurrent heavy turns over ws (session.create β†’ prompt.submit),
   and CONCURRENTLY probes the serving path β€” a ws ``session.list`` round-trip
   AND a REST ``GET /api/status`` β€” every 500ms, timing each.
4. Reports p50/p95/p99 latency for both probes + the count of probes over the
   1s threshold ("serving stalls") + the count of ``event loop stalled`` /
   ``ws write slow`` lines the dashboard logged during the run.

Verdict
-------
The AC-4 verdict comes ONLY from the heavy run: PASS iff serving p99 < 1s AND
zero serving stalls AND zero ``event loop stalled`` log lines over the sustained
window. ``--dry-run`` runs ONE short light turn as a plumbing smoke test and is
explicitly NOT a verdict (a dry-run green is a fake green β€” the spec says so).

Turn isolation is controlled by the ``dashboard.turn_isolation`` config knob in
the scratch HERMES_HOME; ``--isolation on|off`` sets it. Run BOTH:
    iso-certify --isolation off   # baseline: expect stalls
    iso-certify --isolation on    # the measurement that decides AC-4
"""

from __future__ import annotations

import argparse
import json
import os
import re
import socket
import subprocess
import sys
import tempfile
import threading
import time
import shutil
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any

try:
    from websockets.sync.client import connect as ws_connect
except Exception as exc:  # pragma: no cover - dependency guard
    print(f"iso-certify requires the 'websockets' package: {exc}", file=sys.stderr)
    raise

REPO_ROOT = Path(__file__).resolve().parents[1]
_READY_RE = re.compile(r"HERMES_(?:DASHBOARD|BACKEND)_READY port=(\d+)")
_STALL_LOG_RE = re.compile(r"event loop stalled|ws write slow \(loop stalled")


# ── stats ──────────────────────────────────────────────────────────────
def percentile(values: list[float], pct: float) -> float:
    if not values:
        return 0.0
    ordered = sorted(values)
    if len(ordered) == 1:
        return ordered[0]
    rank = (len(ordered) - 1) * (pct / 100.0)
    lo = int(rank)
    hi = min(lo + 1, len(ordered) - 1)
    frac = rank - lo
    return ordered[lo] * (1.0 - frac) + ordered[hi] * frac


def summarize(values: list[float]) -> dict[str, float]:
    return {
        "count": len(values),
        "p50_ms": percentile(values, 50),
        "p95_ms": percentile(values, 95),
        "p99_ms": percentile(values, 99),
        "max_ms": max(values) if values else 0.0,
    }


def free_port() -> int:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return int(s.getsockname()[1])


# ── scratch HERMES_HOME ─────────────────────────────────────────────────
def seed_scratch_home(home: Path, *, isolation: str, heartbeat_secs: int, respawn_max: int) -> None:
    """Write a minimal config.yaml with the isolation knob set."""
    home.mkdir(parents=True, exist_ok=True)
    (home / "state").mkdir(parents=True, exist_ok=True)
    (home / "logs").mkdir(parents=True, exist_ok=True)
    cfg = {
        # Pin the model/provider to what SyntheticHeavyAgent reports so the
        # per-turn _sync_agent_model_with_config sees a match and no-ops β€” a
        # mismatch would try (and fail) a real model switch, erroring the turn
        # before its heavy loop runs (which would be a FALSE green: serving
        # stays responsive because no heavy compute happened). The synthetic
        # seam means no real API call is ever made regardless.
        "provider": "synthetic",
        "model": "synthetic-heavy",
        "dashboard": {
            "turn_isolation": (isolation == "on"),
            "compute_host_heartbeat_secs": heartbeat_secs,
            "compute_host_respawn_max": respawn_max,
        },
        # Keep memory/mem0/skills side-machinery from reaching out.
        "memory": {"enabled": False},
    }
    # config.yaml is the canonical config; write it directly.
    import yaml  # provided by the runtime venv

    (home / "config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=True), encoding="utf-8")
    # A stub .env so credential resolution doesn't spelunk the real home.
    (home / ".env").write_text("OPENAI_API_KEY=sk-synthetic-not-used\n", encoding="utf-8")


# ── dashboard process ───────────────────────────────────────────────────
class ScratchDashboard:
    def __init__(
        self,
        *,
        home: Path,
        port: int,
        isolation: str,
        env_extra: dict[str, str],
    ) -> None:
        self.home = home
        self.port = port
        self.isolation = isolation
        self.env_extra = env_extra
        self.proc: subprocess.Popen[str] | None = None
        self.actual_port = port
        self.log_lines: list[str] = []
        self._log_lock = threading.Lock()
        self._ready = threading.Event()

    @property
    def stall_log_count(self) -> int:
        with self._log_lock:
            return sum(1 for ln in self.log_lines if _STALL_LOG_RE.search(ln))

    def _drain(self, stream: Any) -> None:
        for raw in stream:
            line = raw.rstrip("\n")
            with self._log_lock:
                self.log_lines.append(line)
            m = _READY_RE.search(line)
            if m:
                self.actual_port = int(m.group(1))
                self._ready.set()

    def __enter__(self) -> "ScratchDashboard":
        venv_py = REPO_ROOT / "venv" / "bin" / "python"
        python = str(venv_py) if venv_py.exists() else sys.executable
        env = dict(os.environ)
        env.update(self.env_extra)
        env["HERMES_HOME"] = str(self.home)
        env["HOME"] = str(self.home.parent) if str(self.home.parent) else env.get("HOME", "")
        env["HERMES_HOME"] = str(self.home)
        env["PYTHONPATH"] = str(REPO_ROOT) + os.pathsep + env.get("PYTHONPATH", "")
        env["HERMES_ISO_CERTIFY_SYNTH_TURN"] = "1"
        cmd = [
            python, "-m", "hermes_cli.main", "dashboard",
            "--no-open", "--host", "127.0.0.1", "--port", str(self.port),
        ]
        self.proc = subprocess.Popen(
            cmd, cwd=str(REPO_ROOT), env=env,
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1,
        )
        threading.Thread(target=self._drain, args=(self.proc.stdout,), name="dash-log", daemon=True).start()
        if not self._ready.wait(timeout=90.0):
            self._dump_tail()
            raise RuntimeError("scratch dashboard did not become ready within 90s")
        # Give uvicorn a beat to actually bind the ws route.
        time.sleep(1.0)
        return self

    def __exit__(self, *_exc: object) -> None:
        if self.proc is None:
            return
        try:
            self.proc.terminate()
            self.proc.wait(timeout=10)
        except Exception:
            try:
                self.proc.kill()
            except Exception:
                pass

    def _dump_tail(self, n: int = 40) -> None:
        with self._log_lock:
            tail = self.log_lines[-n:]
        sys.stderr.write("---- scratch dashboard log tail ----\n")
        for ln in tail:
            sys.stderr.write(ln + "\n")
        sys.stderr.write("------------------------------------\n")


# ── ws client (one connection = one lane) ───────────────────────────────
class WSClient:
    def __init__(self, port: int, token: str) -> None:
        self.url = f"ws://127.0.0.1:{port}/api/ws?token={token}"
        self.ws = ws_connect(
            self.url,
            open_timeout=15,
            max_size=None,
            additional_headers={"Origin": f"http://127.0.0.1:{port}"},
        )
        self._id = 0
        self._lock = threading.Lock()
        # Drain the gateway.ready event.
        self._recv_until(lambda o: o.get("method") == "event", timeout=10)

    def _next_id(self) -> str:
        with self._lock:
            self._id += 1
            return f"r{self._id}"

    def _recv_until(self, pred, timeout: float) -> dict:
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            try:
                raw = self.ws.recv(timeout=max(0.05, deadline - time.monotonic()))
            except TimeoutError:
                continue
            try:
                obj = json.loads(raw)
            except Exception:
                continue
            if pred(obj):
                return obj
        raise TimeoutError("ws recv predicate timed out")

    def rpc(self, method: str, params: dict, timeout: float = 30.0) -> dict:
        rid = self._next_id()
        self.ws.send(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}))
        return self._recv_until(lambda o: o.get("id") == rid, timeout=timeout)

    def send_only(self, method: str, params: dict) -> str:
        rid = self._next_id()
        self.ws.send(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}))
        return rid

    def close(self) -> None:
        try:
            self.ws.close()
        except Exception:
            pass


# ── heavy-turn lane ─────────────────────────────────────────────────────
def drive_heavy_turn(port: int, token: str, turn_spec: dict, stop_at: float, results: list[dict]) -> None:
    """One lane: create a session, submit heavy turns until the deadline."""
    lane: dict[str, Any] = {"turns": 0, "errors": [], "turn_durations_s": [], "min_deltas": None}
    try:
        cli = WSClient(port, token)
    except Exception as exc:
        lane["errors"].append(f"connect: {exc}")
        results.append(lane)
        return
    try:
        resp = cli.rpc("session.create", {"cols": 80, "source": "iso-certify"}, timeout=30)
        sid = ((resp.get("result") or {}).get("session_id")) or ((resp.get("result") or {}).get("id"))
        if not sid:
            lane["errors"].append(f"session.create bad resp: {resp}")
            results.append(lane)
            return
        lane["sid"] = sid
        spec_text = json.dumps(turn_spec)
        while time.monotonic() < stop_at:
            # prompt.submit returns immediately ("streaming"); the turn runs async.
            r = cli.rpc("prompt.submit", {"session_id": sid, "text": spec_text}, timeout=30)
            if r.get("error"):
                lane["errors"].append(f"prompt.submit: {r['error']}")
                break
            # Wait for the REAL turn boundary. The isolated path emits
            # session.info MID-turn (metadata mirror), so waiting on it would
            # false-complete a turn in <1s and inflate the count while NO heavy
            # compute ran β€” the acceptance-gate "proxy not effect" trap. The
            # turn is done only on message.complete. Count deltas + duration so
            # a fast-erroring turn (e.g. a failed model switch) is caught, not
            # masked as sustained load.
            per_turn_budget = turn_spec.get("duration_s", 8.0) + 30.0
            deadline = time.monotonic() + per_turn_budget
            turn_start = time.monotonic()
            deltas = 0
            started = False
            done = False
            errored = False
            while time.monotonic() < deadline:
                try:
                    o = cli._recv_until(
                        lambda o: o.get("method") == "event"
                        and (o.get("params") or {}).get("type")
                        in {"message.start", "message.delta", "message.complete", "error"},
                        timeout=max(0.5, deadline - time.monotonic()),
                    )
                except TimeoutError:
                    break
                ptype = (o.get("params") or {}).get("type")
                if ptype == "message.start":
                    started = True
                    turn_start = time.monotonic()
                    deltas = 0
                    continue
                # prompt.submit's RPC response may race with a duplicate terminal
                # event from the previous turn. Only events after this turn's
                # message.start can satisfy or score its load boundary.
                if not started:
                    continue
                if ptype == "message.delta":
                    deltas += 1
                    continue
                if ptype == "error":
                    msg = str(((o.get("params") or {}).get("payload") or {}).get("message") or "")
                    lane["errors"].append(f"turn error: {msg[:160]}")
                    errored = True
                    break
                if ptype == "message.complete":
                    done = True
                    break
            turn_dur = time.monotonic() - turn_start
            if done:
                lane["turns"] += 1
                lane["turn_durations_s"].append(round(turn_dur, 2))
                lane["min_deltas"] = deltas if lane["min_deltas"] is None else min(lane["min_deltas"], deltas)
            if errored:
                break
    except Exception as exc:
        lane["errors"].append(f"lane: {exc}")
    finally:
        cli.close()
        results.append(lane)


# ── serving-path probes ─────────────────────────────────────────────────
def warmup_serving(port: int, token: str, rounds: int = 6) -> None:
    """Prime serving-path caches before the measured window.

    The first ``/api/status`` on a freshly-booted process pays one-time
    cold-start cost (config-version check, gateway-health probe, DB connect) that
    a real dashboard β€” warm for hours before the incident regime β€” never pays
    during a stall. AC-4 measures sustained-load responsiveness, not cold boot,
    so we hit both serving endpoints a few times UNMEASURED first. This is not a
    green-washing shortcut: the measured window still runs the full 6-lane heavy
    load; warmup only removes a one-time boot artifact from the p99.
    """
    rest_url = f"http://127.0.0.1:{port}/api/status"
    warm_ws: WSClient | None = None
    try:
        warm_ws = WSClient(port, token)
    except Exception:
        warm_ws = None
    for _ in range(rounds):
        try:
            with urllib.request.urlopen(rest_url, timeout=30) as fh:
                fh.read()
        except Exception:
            pass
        if warm_ws is not None:
            try:
                warm_ws.rpc("session.list", {"limit": 20}, timeout=30)
            except Exception:
                pass
        time.sleep(0.2)
    if warm_ws is not None:
        warm_ws.close()


def probe_loop(port: int, token: str, stop_at: float, cadence_s: float, ws_samples: list[float], rest_samples: list[float]) -> None:
    """Probe ws session.list + REST /api/status every ``cadence_s`` seconds."""
    probe_ws: WSClient | None = None
    try:
        probe_ws = WSClient(port, token)
    except Exception:
        probe_ws = None
    rest_url = f"http://127.0.0.1:{port}/api/status"
    next_tick = time.monotonic()
    while time.monotonic() < stop_at:
        # ws round-trip (session.list β€” the serving-plane DB read AC-4 protects).
        if probe_ws is not None:
            t0 = time.perf_counter()
            try:
                probe_ws.rpc("session.list", {"limit": 20}, timeout=30)
                ws_samples.append((time.perf_counter() - t0) * 1000.0)
            except Exception:
                # A failed/timed-out probe is itself a serving stall; record the
                # elapsed as the sample so it counts against p99.
                ws_samples.append((time.perf_counter() - t0) * 1000.0)
                try:
                    probe_ws.close()
                    probe_ws = WSClient(port, token)
                except Exception:
                    probe_ws = None
        # REST round-trip.
        t1 = time.perf_counter()
        try:
            with urllib.request.urlopen(rest_url, timeout=30) as fh:
                fh.read()
            rest_samples.append((time.perf_counter() - t1) * 1000.0)
        except Exception:
            rest_samples.append((time.perf_counter() - t1) * 1000.0)
        next_tick += cadence_s
        sleep_for = next_tick - time.monotonic()
        if sleep_for > 0:
            time.sleep(sleep_for)
        else:
            next_tick = time.monotonic()
    if probe_ws is not None:
        probe_ws.close()


# ── run ─────────────────────────────────────────────────────────────────
def run_certify(args: argparse.Namespace) -> dict[str, Any]:
    port = free_port()
    import secrets
    token = secrets.token_urlsafe(24)
    parent_tmp = Path(tempfile.mkdtemp(prefix="iso-certify-"))
    home = parent_tmp / "hermes-home"
    seed_scratch_home(
        home,
        isolation=args.isolation,
        heartbeat_secs=args.heartbeat_secs,
        respawn_max=args.respawn_max,
    )

    concurrency = 1 if args.dry_run else args.concurrency
    duration_s = 3.0 if args.dry_run else args.duration_s
    turn_duration = 0.5 if args.dry_run else args.turn_duration_s
    threshold_ms = args.threshold_ms

    turn_spec = {
        "duration_s": turn_duration,
        "delta_interval_s": args.delta_interval_s,
        "tokens_per_delta": args.tokens_per_delta,
        "chunk": args.chunk,
    }

    result: dict[str, Any] = {
        "mode": "dry-run" if args.dry_run else "heavy",
        "isolation": args.isolation,
        "concurrency": concurrency,
        "run_duration_s": duration_s,
        "turn_spec": turn_spec,
        "threshold_ms": threshold_ms,
        "scratch_home": str(home),
        "port": port,
    }

    try:
        with ScratchDashboard(
            home=home, port=port, isolation=args.isolation,
            env_extra={"HERMES_DASHBOARD_SESSION_TOKEN": token},
        ) as dash:
            actual_port = dash.actual_port
            result["port"] = actual_port
            ws_samples: list[float] = []
            rest_samples: list[float] = []
            lane_results: list[dict] = []
            stall_before = dash.stall_log_count

            # Prime serving-path caches so a one-time cold-start artifact does
            # not count as a serving stall (only for the real heavy run; a
            # dry-run stays a raw plumbing smoke).
            if not args.dry_run:
                warmup_serving(actual_port, token)
                stall_before = dash.stall_log_count

            stop_at = time.monotonic() + duration_s
            probe_thread = threading.Thread(
                target=probe_loop,
                args=(actual_port, token, stop_at, args.cadence_s, ws_samples, rest_samples),
                name="probe", daemon=True,
            )
            probe_thread.start()

            with ThreadPoolExecutor(max_workers=concurrency) as pool:
                for _ in range(concurrency):
                    pool.submit(drive_heavy_turn, actual_port, token, turn_spec, stop_at, lane_results)
                # Pool context waits for all lanes.
            probe_thread.join(timeout=30)

            stall_after = dash.stall_log_count
            total_turns = sum(l.get("turns", 0) for l in lane_results)
            lane_errors = [e for l in lane_results for e in l.get("errors", [])]
            all_durations = [d for l in lane_results for d in l.get("turn_durations_s", [])]
            min_deltas = [l["min_deltas"] for l in lane_results if l.get("min_deltas") is not None]
            lanes_with_turn = sum(1 for l in lane_results if l.get("turns", 0) > 0)

            ws_stat = summarize(ws_samples)
            rest_stat = summarize(rest_samples)
            ws_over = sum(1 for v in ws_samples if v > threshold_ms)
            rest_over = sum(1 for v in rest_samples if v > threshold_ms)
            serving_stalls = ws_over + rest_over
            log_stalls = stall_after - stall_before

            # Load validity β€” the run only certifies anything if the offered load
            # was REAL: every lane completed β‰₯1 turn, and the completed turns
            # actually held ~the requested heavy duration and streamed deltas.
            # A fast-erroring/short turn is NOT sustained GIL load, so a green off
            # it would be a proxy (serving stays fast because nothing burned).
            median_turn_dur = percentile(all_durations, 50) if all_durations else 0.0
            min_turn_dur = min(all_durations) if all_durations else 0.0
            worst_min_deltas = min(min_deltas) if min_deltas else 0
            expected_turn_s = float(turn_spec.get("duration_s", 8.0))
            load_valid = (
                lanes_with_turn >= concurrency
                and total_turns >= concurrency
                and min_turn_dur >= 0.7 * expected_turn_s
                and worst_min_deltas >= 5
            )

            result.update({
                "ws_probe": ws_stat,
                "rest_probe": rest_stat,
                "ws_probes_over_threshold": ws_over,
                "rest_probes_over_threshold": rest_over,
                "serving_stalls": serving_stalls,
                "event_loop_stall_log_lines": log_stalls,
                "heavy_turns_completed": total_turns,
                "lanes_with_turn": lanes_with_turn,
                "median_turn_duration_s": round(median_turn_dur, 2),
                "min_turn_duration_s": round(min_turn_dur, 2),
                "worst_lane_min_deltas": worst_min_deltas,
                "load_valid": load_valid,
                "lane_errors": lane_errors[:20],
            })

            # AC-4 verdict β€” heavy run only. A dry-run reports but never PASSes.
            serving_p99 = max(ws_stat["p99_ms"], rest_stat["p99_ms"])
            result["serving_p99_ms"] = serving_p99
            if args.dry_run:
                result["verdict"] = "SMOKE-OK" if (total_turns > 0 and not lane_errors) else "SMOKE-FAIL"
                result["is_verdict"] = False
            else:
                serving_ok = (
                    serving_p99 < threshold_ms
                    and serving_stalls == 0
                    and log_stalls == 0
                    and probe_thread_samples_ok(ws_samples, rest_samples)
                )
                if not load_valid:
                    # Cannot certify: the offered load was not the incident
                    # regime. Never a PASS; report INCONCLUSIVE, not FAIL, so it
                    # is not read as "isolation broke serving".
                    result["verdict"] = "INCONCLUSIVE"
                    result.setdefault("notes", []).append(
                        "load invalid: lanes/turn-duration/deltas below the sustained-heavy-load floor β€” "
                        "not the AC-4 incident regime, verdict cannot certify"
                    )
                else:
                    result["verdict"] = "PASS" if serving_ok else "FAIL"
                result["is_verdict"] = True
    finally:
        if not args.keep_home:
            shutil.rmtree(parent_tmp, ignore_errors=True)
        else:
            result["scratch_home_kept"] = str(parent_tmp)

    return result


def probe_thread_samples_ok(ws_samples: list[float], rest_samples: list[float]) -> bool:
    """Guard against a blind gate: require the probes actually ran.

    A run that produced no probe samples saw NOTHING β€” it must not PASS. This is
    the tri-state INCONCLUSIVE floor (an empty timeline is never a green).
    """
    return len(ws_samples) >= 3 and len(rest_samples) >= 3


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(description="AC-4 dashboard turn-isolation certify harness")
    p.add_argument("--isolation", choices=["on", "off"], default="on",
                   help="set dashboard.turn_isolation in the scratch HERMES_HOME")
    p.add_argument("--dry-run", action="store_true",
                   help="1 short light turn plumbing smoke β€” NOT an AC-4 verdict")
    p.add_argument("--concurrency", type=int, default=6, help="concurrent heavy-turn lanes (AC-4: 6)")
    p.add_argument("--duration-s", type=float, default=600.0, dest="duration_s",
                   help="sustained run window seconds (AC-4: ~600 = 10 min)")
    p.add_argument("--turn-duration-s", type=float, default=12.0, dest="turn_duration_s",
                   help="wall seconds of GIL-holding compute per heavy turn")
    p.add_argument("--delta-interval-s", type=float, default=0.05, dest="delta_interval_s",
                   help="streamed-delta cadence per heavy turn")
    p.add_argument("--tokens-per-delta", type=int, default=512, dest="tokens_per_delta")
    p.add_argument("--chunk", type=int, default=20000, help="pure-Python ops per interrupt-check chunk")
    p.add_argument("--cadence-s", type=float, default=0.5, dest="cadence_s",
                   help="serving-path probe cadence (AC-4: 500ms)")
    p.add_argument("--threshold-ms", type=float, default=1000.0, dest="threshold_ms",
                   help="serving p99 threshold (AC-4: <1s)")
    p.add_argument("--heartbeat-secs", type=int, default=15, dest="heartbeat_secs")
    p.add_argument("--respawn-max", type=int, default=3, dest="respawn_max")
    p.add_argument("--keep-home", action="store_true", help="do not delete the scratch HERMES_HOME on exit")
    p.add_argument("--json-out", type=Path, help="write JSON metrics to this path")
    args = p.parse_args(argv)

    result = run_certify(args)
    text = json.dumps(result, indent=2, sort_keys=True)
    print(text)
    if args.json_out:
        args.json_out.parent.mkdir(parents=True, exist_ok=True)
        args.json_out.write_text(text + "\n", encoding="utf-8")

    # Exit code: 0 only on a real PASS (or a clean dry-run smoke); non-zero
    # otherwise. A dry-run is never treated as a verdict for automation.
    if result.get("is_verdict"):
        return 0 if result.get("verdict") == "PASS" else 1
    return 0 if result.get("verdict") == "SMOKE-OK" else 1


if __name__ == "__main__":
    raise SystemExit(main())