From 3578d1d487e23c333325cd14ff93a5fe3fc72f85 Mon Sep 17 00:00:00 2001 From: Hermes Agent <51974392+tcconnally@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:10:16 +0000 Subject: [PATCH] fix(macos): accept docked clamshell power evidence --- hyperwall/diagnostics.py | 65 ++++++++++++++++++++++++--- soak_wall.sh | 8 +++- tests/test_diagnostics.py | 86 ++++++++++++++++++++++++++++++++++++ tests/test_soak_telemetry.py | 2 + 4 files changed, 153 insertions(+), 8 deletions(-) diff --git a/hyperwall/diagnostics.py b/hyperwall/diagnostics.py index 97b27b7..e89e75e 100644 --- a/hyperwall/diagnostics.py +++ b/hyperwall/diagnostics.py @@ -429,8 +429,11 @@ def _find_file(root: Path, names: tuple[str, ...]) -> Path | None: return None -def _power_sleep_summary(path: Path | None) -> dict[str, Any]: - """Summarize independent AC/lid/sleep evidence without calling macOS tools.""" +def _power_sleep_summary( + path: Path | None, + run_env_path: Path | None = None, +) -> dict[str, Any]: + """Summarize AC, sleep, and open-lid/docked-clamshell evidence.""" if path is None: return { "path": None, @@ -439,13 +442,29 @@ def _power_sleep_summary(path: Path | None) -> dict[str, Any]: "assertion_samples": 0, "ac_power_samples": 0, "lid_open_samples": 0, + "lid_closed_samples": 0, + "lid_evidence_samples": 0, "ac_power_observed": False, "lid_open_observed": False, + "lid_closed_observed": False, + "external_display_observed": False, + "docked_clamshell_observed": False, + "lid_evidence_mode": None, } try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: text = "" + if run_env_path is None: + candidate = path.parent / "run.env" + run_env_path = candidate if candidate.is_file() else None + try: + run_env = ( + run_env_path.read_text(encoding="utf-8", errors="replace") + if run_env_path is not None else "" + ) + except OSError: + run_env = "" sections = re.split(r"(?m)^=== .* ===$", text)[1:] samples = len(sections) assertion_samples = sum( @@ -472,8 +491,33 @@ def _power_sleep_summary(path: Path | None) -> dict[str, Any]: ) for section in sections ) - ac_power = ac_power_samples > 0 + lid_closed_samples = sum( + bool( + re.search( + r'"?AppleClamshellState"?\s*=\s*(?:Yes|1|true)', + section, + re.IGNORECASE, + ) + ) + for section in sections + ) + external_display_observed = bool( + re.search(r"(?im)^\s*external_display_observed\s*=\s*1\s*$", run_env) + ) lid_open = lid_open_samples > 0 + lid_closed = lid_closed_samples > 0 + docked_clamshell = lid_closed and external_display_observed + lid_evidence_samples = max( + lid_open_samples, + lid_closed_samples if external_display_observed else 0, + ) + lid_evidence_mode = ( + "open" + if lid_open_samples > 0 + else "docked_clamshell" + if docked_clamshell + else None + ) return { "path": str(path), "present": bool(text.strip()), @@ -481,8 +525,14 @@ def _power_sleep_summary(path: Path | None) -> dict[str, Any]: "assertion_samples": assertion_samples, "ac_power_samples": ac_power_samples, "lid_open_samples": lid_open_samples, - "ac_power_observed": ac_power, + "lid_closed_samples": lid_closed_samples, + "lid_evidence_samples": lid_evidence_samples, + "ac_power_observed": ac_power_samples > 0, "lid_open_observed": lid_open, + "lid_closed_observed": lid_closed, + "external_display_observed": external_display_observed, + "docked_clamshell_observed": docked_clamshell, + "lid_evidence_mode": lid_evidence_mode, } @@ -793,7 +843,7 @@ def analyze_run( stats_candidates = sorted(_safe_children(root, "hyperwall_stats_*.json")) stats_path = stats_candidates[0] if len(stats_candidates) == 1 else None power_path = _find_file(root, ("power_sleep.log",)) - power_summary = _power_sleep_summary(power_path) + power_summary = _power_sleep_summary(power_path, root / "run.env") stats_valid = _has_valid_stats(stats_path) stats_summary = _stats_summary(stats_path) manifest = ( @@ -973,7 +1023,7 @@ def analyze_run( power_summary["samples"] >= minimum_power_samples and power_summary["assertion_samples"] >= minimum_power_samples and power_summary["ac_power_samples"] >= minimum_power_samples - and power_summary["lid_open_samples"] >= minimum_power_samples + and power_summary["lid_evidence_samples"] >= minimum_power_samples ) power_complete = ( power_summary["present"] @@ -982,7 +1032,8 @@ def analyze_run( gates["power_sleep_evidence"] = _gate( "PASS" if power_complete else "BLOCK" if expected_duration_seconds is not None else "WARNING", power_summary, - "A live soak requires independent AC-power, lid, and sleep-assertion evidence; " + "A live soak requires independent AC-power, sleep assertions, and " + "either an open lid or verified docked-clamshell external-display evidence; " "caffeinate alone is not proof that the Mac stayed awake.", ) for key, note in ( diff --git a/soak_wall.sh b/soak_wall.sh index 622f0fa..60b3b88 100755 --- a/soak_wall.sh +++ b/soak_wall.sh @@ -37,7 +37,13 @@ PIDS=() printf 'host='; sw_vers printf 'hardware='; system_profiler SPHardwareDataType 2>/dev/null || true printf 'env=HYPERWALL_SOAK_MINUTES=%s HYPERWALL_SOAK_DWELL_S=%s HYPERWALL_SOAK_PROFILE=%s HYPERWALL_HWDEC=%s HYPERWALL_CACHE_BUDGET_MB=%s HYPERWALL_DEMUXER_PER_CELL_MB=%s\n' "$HYPERWALL_SOAK_MINUTES" "$HYPERWALL_SOAK_DWELL_S" "$HYPERWALL_SOAK_PROFILE" "${HYPERWALL_HWDEC:-}" "${HYPERWALL_CACHE_BUDGET_MB:-}" "${HYPERWALL_DEMUXER_PER_CELL_MB:-}" - printf 'power_evidence_required=AC_power_lid_open_no_sleep\n' + display_probe="$(system_profiler SPDisplaysDataType 2>/dev/null || true)" + if printf '%s\n' "$display_probe" | grep -Eqi 'Display Type: External|Connection Type: (DisplayPort|HDMI|DVI|USB|Thunderbolt|AirPlay)'; then + printf 'external_display_observed=1\n' + else + printf 'external_display_observed=0\n' + fi + printf 'power_evidence_required=AC_power_sleep_assertions_open_lid_or_docked_clamshell\n' printf 'power_evidence_artifact=power_sleep.log\n' } > "$REPORT_DIR/run.env" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 0c5c95c..e89921e 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -210,6 +210,92 @@ def test_power_sleep_summary_accepts_quoted_ioreg_clamshell_key(): assert summary["lid_open_observed"] is True +def test_power_sleep_summary_accepts_docked_clamshell_with_external_display(): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + power_path = root / "power_sleep.log" + power_path.write_text( + "=== sample ===\n" + "--- pmset -g ps ---\n" + "Now drawing from 'AC Power'\n" + "--- pmset -g assertions ---\n" + "PreventSystemSleep 1\n" + "--- ioreg AppleClamshellState ---\n" + '"AppleClamshellState" = Yes\n', + encoding="utf-8", + ) + (root / "run.env").write_text( + "external_display_observed=1\n", + encoding="utf-8", + ) + summary = _power_sleep_summary(power_path) + + assert summary["lid_closed_observed"] is True + assert summary["external_display_observed"] is True + assert summary["docked_clamshell_observed"] is True + + +def test_analyze_run_accepts_docked_clamshell_power_evidence(): + cells = [ + { + "cell": 0, + "totals": {"frame-drop-count": 0}, + "info": {"hwdec-current": "no"}, + "freezes": 0, + "freeze_seconds": 0, + } + ] + records = [ + {"event": "start", "baseline": {"ws_mb": 1}}, + { + "event": "sample", + "wall_seconds": 30, + "cells": 1, + "actions": {}, + "resources": {"ws_mb": 1}, + }, + { + "event": "finish", + "wall_seconds": 60, + "resources": {"ws_mb": 1}, + "invariant_violations": 0, + }, + ] + power_sample = ( + "=== sample ===\n" + "Now drawing from 'AC Power'\n" + "PreventSystemSleep 1\n" + '"AppleClamshellState" = Yes\n' + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "hyperwall.log").write_text( + "Runtime: Hyperwall\nSOAK start:\n", + encoding="utf-8", + ) + (root / "hyperwall_stats_a.json").write_text( + json.dumps({"cells": cells}), + encoding="utf-8", + ) + (root / "hyperwall_soak_a.jsonl").write_text( + "\n".join(json.dumps(record) for record in records) + "\n", + encoding="utf-8", + ) + (root / "power_sleep.log").write_text(power_sample * 4, encoding="utf-8") + (root / "run.env").write_text( + "external_display_observed=1\n", + encoding="utf-8", + ) + result = analyze_run( + root, + expected_cells=1, + expected_duration_seconds=60, + ) + + assert result["gates"]["power_sleep_evidence"]["status"] == "PASS" + assert result["gates"]["power_sleep_evidence"]["value"]["docked_clamshell_observed"] is True + + def test_power_sleep_summary_rejects_empty_assertion_output(): with tempfile.TemporaryDirectory() as directory: path = Path(directory, "power_sleep.log") diff --git a/tests/test_soak_telemetry.py b/tests/test_soak_telemetry.py index d841509..98d8916 100644 --- a/tests/test_soak_telemetry.py +++ b/tests/test_soak_telemetry.py @@ -216,6 +216,8 @@ def test_macos_soak_launcher_collects_system_telemetry(): "HYPERWALL_STATS=1", "HYPERWALL_PERFTRACE=1", "powermetrics", + "SPDisplaysDataType", + "external_display_observed", "nettop", "vm_stat", "chmod 700",