From 729b51f4827d51ab636fefe1088b28518b00edc8 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 13 Jul 2026 12:27:50 +0300 Subject: [PATCH 1/3] fix: keep tier0 health checks bounded --- docs/operations/brainlayer-health-check.md | 21 +- .../com.brainlayer.tier0-watchdog.plist | 6 + scripts/tier0-watchdog.sh | 51 ++- src/brainlayer/health_check.py | 295 +++++++++++++++--- tests/test_installable_build.py | 5 + tests/test_stability_health_check.py | 116 ++++++- tests/test_tier0_drills.py | 52 ++- 7 files changed, 488 insertions(+), 58 deletions(-) diff --git a/docs/operations/brainlayer-health-check.md b/docs/operations/brainlayer-health-check.md index a0ba7b11..76e6aa05 100644 --- a/docs/operations/brainlayer-health-check.md +++ b/docs/operations/brainlayer-health-check.md @@ -15,6 +15,14 @@ brainlayer health-check --json --heal - Active chunks missing semantic vectors are decreasing across ticks. One unchanged tick is tolerated; the second unchanged tick alarms. - BrainBar's served MCP socket can answer a `brain_search` canary with at least one result. +The missing-vector count is exact, but it computes the ID difference through the +covering `chunks` and `chunk_vectors_rowids` indexes before reading chunk payloads. +This keeps the scheduled check independent of database payload size. + +The check has a 45-second internal deadline. A query that exhausts that budget is +interrupted and the state file is still refreshed with `slow_check: true`, the +stage name, and the elapsed duration. + ## Self-Heal With `--heal`, the check uses `launchctl kickstart -k` for cheap recovery: @@ -22,7 +30,18 @@ With `--heal`, the check uses `launchctl kickstart -k` for cheap recovery: - `com.brainlayer.hotlane-brainbar` when hotlane is dead, backlog is disabled, or missing vectors are climbing/stalled. - `com.brainlayer.brainbar-daemon` when the BrainBar MCP canary fails or returns zero results. -The command always writes the latest missing-vector count to `~/.local/share/brainlayer/health-check-state.json`. +Before a kickstart, the check reads the launchd process state. Processes in an +uninterruptible `U`/`D` state are not kickstarted repeatedly; the check records a +`heal_backoff` action and the existing circuit breaker escalates after repeated +failed ticks. + +The command writes the latest successfully measured missing-vector count to +`~/.local/share/brainlayer/health-check-state.json`; a timed-out tick preserves +the previous count and marks the state as slow. + +The independent Tier-0 watchdog treats that state as stale after 900 seconds. +Its first alert is immediate; repeat alerts for the same failure class are +suppressed for 1,800 seconds while recovery attempts continue. ## Logs diff --git a/scripts/launchd/com.brainlayer.tier0-watchdog.plist b/scripts/launchd/com.brainlayer.tier0-watchdog.plist index 8140e760..42a07fd6 100644 --- a/scripts/launchd/com.brainlayer.tier0-watchdog.plist +++ b/scripts/launchd/com.brainlayer.tier0-watchdog.plist @@ -15,6 +15,12 @@ __HOME__ PATH /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + TIER0_ALERT_STATE_PATH + __HOME__/.local/share/brainlayer/tier0-watchdog-alert-state + TIER0_REPEAT_ALERT_SECONDS + 1800 + TIER0_STALE_SECONDS + 900 StandardOutPath __HOME__/Library/Logs/brainlayer/tier0-watchdog.out.log diff --git a/scripts/tier0-watchdog.sh b/scripts/tier0-watchdog.sh index 95c2dfed..61407ec1 100755 --- a/scripts/tier0-watchdog.sh +++ b/scripts/tier0-watchdog.sh @@ -26,8 +26,10 @@ fi TIER0_STATE_PATH=${TIER0_STATE_PATH:-$HOME/.local/share/brainlayer/health-check-state.json} TIER0_HEALTH_PLIST_PATH=${TIER0_HEALTH_PLIST_PATH:-$HOME/Library/LaunchAgents/com.brainlayer.health-check.plist} TIER0_LOG_PATH=${TIER0_LOG_PATH:-$HOME/.local/share/brainlayer/logs/tier0-watchdog.log} +TIER0_ALERT_STATE_PATH=${TIER0_ALERT_STATE_PATH:-$HOME/.local/share/brainlayer/tier0-watchdog-alert-state} TIER0_NOTIFY_ENDPOINT=${TIER0_NOTIFY_ENDPOINT:-http://localhost:3847/notify} -TIER0_STALE_SECONDS=${TIER0_STALE_SECONDS:-1200} +TIER0_STALE_SECONDS=${TIER0_STALE_SECONDS:-900} +TIER0_REPEAT_ALERT_SECONDS=${TIER0_REPEAT_ALERT_SECONDS:-1800} TIER0_ALERT_TIMEOUT_SECONDS=${TIER0_ALERT_TIMEOUT_SECONDS:-3} TIER0_NOTIFY_TIMEOUT_SECONDS=${TIER0_NOTIFY_TIMEOUT_SECONDS:-3} @@ -54,6 +56,7 @@ require_epoch() { } require_positive_integer TIER0_STALE_SECONDS "$TIER0_STALE_SECONDS" +require_positive_integer TIER0_REPEAT_ALERT_SECONDS "$TIER0_REPEAT_ALERT_SECONDS" require_positive_integer TIER0_ALERT_TIMEOUT_SECONDS "$TIER0_ALERT_TIMEOUT_SECONDS" require_positive_integer TIER0_NOTIFY_TIMEOUT_SECONDS "$TIER0_NOTIFY_TIMEOUT_SECONDS" @@ -121,33 +124,71 @@ alert_all_channels() { wait_for_alerts "$log_pid" "$osascript_pid" "$curl_pid" } +should_alert() { + failure_key=$1 + if [ ! -f "$TIER0_ALERT_STATE_PATH" ]; then + return 0 + fi + + last_alert_epoch= + last_failure_key= + IFS="$(printf '\t')" read -r last_alert_epoch last_failure_key < "$TIER0_ALERT_STATE_PATH" || return 0 + case "$last_alert_epoch" in + ''|*[!0-9]*) return 0 ;; + esac + if [ "$last_failure_key" != "$failure_key" ] || [ "$last_alert_epoch" -gt "$now_epoch" ]; then + return 0 + fi + alert_age=$((now_epoch - last_alert_epoch)) + [ "$alert_age" -ge "$TIER0_REPEAT_ALERT_SECONDS" ] +} + +record_alert() { + failure_key=$1 + alert_state_dir=$($TIER0_DIRNAME "$TIER0_ALERT_STATE_PATH" 2>/dev/null) || return 1 + "$TIER0_MKDIR" -p "$alert_state_dir" 2>/dev/null || return 1 + printf '%s\t%s\n' "$now_epoch" "$failure_key" > "$TIER0_ALERT_STATE_PATH" +} + +reset_alert_cooldown() { + if [ -f "$TIER0_ALERT_STATE_PATH" ]; then + printf '0\tok\n' > "$TIER0_ALERT_STATE_PATH" + fi +} + target="$TIER0_DOMAIN/$TIER0_LABEL" failure_reason= +failure_key= label_loaded=0 if "$TIER0_LAUNCHCTL" print "$target" >/dev/null 2>&1; then label_loaded=1 else failure_reason=label_unloaded + failure_key=label_unloaded fi if [ "$label_loaded" -eq 1 ]; then if [ ! -f "$TIER0_STATE_PATH" ]; then failure_reason=state_missing + failure_key=state_missing else state_mtime=$($TIER0_STAT -f %m "$TIER0_STATE_PATH" 2>/dev/null) || state_mtime= case "$state_mtime" in ''|*[!0-9]*) failure_reason=state_mtime_unreadable + failure_key=state_mtime_unreadable ;; *) if [ "$state_mtime" -gt "$now_epoch" ]; then future_offset=$((state_mtime - now_epoch)) failure_reason="state_mtime_future offset=${future_offset}s" + failure_key=state_mtime_future else state_age=$((now_epoch - state_mtime)) if [ "$state_age" -ge "$TIER0_STALE_SECONDS" ]; then failure_reason="state_stale age=${state_age}s threshold=${TIER0_STALE_SECONDS}s" + failure_key=state_stale fi fi ;; @@ -156,11 +197,15 @@ if [ "$label_loaded" -eq 1 ]; then fi if [ -z "$failure_reason" ]; then + reset_alert_cooldown exit 0 fi -# Detection and all alert attempts intentionally precede every recovery command. -alert_all_channels "$failure_reason" +# Detection and all due alert attempts intentionally precede every recovery command. +if should_alert "$failure_key"; then + alert_all_channels "$failure_reason" + record_alert "$failure_key" || : +fi if [ "$label_loaded" -eq 0 ]; then "$TIER0_LAUNCHCTL" bootstrap "$TIER0_DOMAIN" "$TIER0_HEALTH_PLIST_PATH" >/dev/null 2>&1 || : diff --git a/src/brainlayer/health_check.py b/src/brainlayer/health_check.py index 260e87c6..95d3eb1b 100644 --- a/src/brainlayer/health_check.py +++ b/src/brainlayer/health_check.py @@ -10,6 +10,7 @@ import sqlite3 import subprocess import sys +import time from dataclasses import asdict, dataclass, field, replace from datetime import UTC, datetime from pathlib import Path @@ -43,8 +44,45 @@ DEFAULT_BACKLOG_BATCH = 4 DEFAULT_HEAL_MIN_CONSECUTIVE_FAILURES = 2 DEFAULT_HEAL_CIRCUIT_BREAKER_LIMIT = 3 +DEFAULT_MAX_DURATION_SECONDS = 45.0 HEAL_MIN_CONSECUTIVE_FAILURES_ENV = "BRAINLAYER_HEAL_MIN_CONSECUTIVE_FAILURES" +MISSING_EMBEDDINGS_SQL = """ + SELECT COUNT(*) + FROM chunks c + JOIN ( + SELECT id FROM chunks + EXCEPT + SELECT id FROM chunk_vectors_rowids + ) missing ON missing.id = c.id + WHERE c.content IS NOT NULL + AND c.content != '' + AND c.archived_at IS NULL + AND c.superseded_by IS NULL + AND c.aggregated_into IS NULL + AND COALESCE(c.archived, 0) = 0 + AND COALESCE(c.status, 'active') = 'active' +""" + +ENRICHMENT_BACKLOG_SQL = """ + SELECT COUNT(*) + FROM chunks + WHERE enriched_at IS NULL + AND enrich_status IS NULL + AND COALESCE(char_count, length(content), 0) >= 50 + AND content IS NOT NULL + AND content != '' + AND archived_at IS NULL + AND superseded_by IS NULL + AND aggregated_into IS NULL + AND COALESCE(archived, 0) = 0 + AND COALESCE(status, 'active') = 'active' +""" + + +class HealthCheckDeadlineExceeded(RuntimeError): + """Raised when a read-only health query consumes the check's time budget.""" + def _env_int(name: str, default: int, *, minimum: int = 1) -> int: raw_value = os.environ.get(name) @@ -113,6 +151,7 @@ class HealthCheckConfig: queue_page_oldest_seconds: int = 4 * 60 * 60 queue_page_bytes: int = 2 * 1024 * 1024 * 1024 heal_circuit_breaker_limit: int = DEFAULT_HEAL_CIRCUIT_BREAKER_LIMIT + max_duration_seconds: float = DEFAULT_MAX_DURATION_SECONDS heal: bool = False socket_timeout_seconds: float = 5.0 max_stalled_ticks: int = 2 @@ -153,6 +192,9 @@ class HealthCheckResult: lock_holder: LockHolder | None = None canary_ok: bool = False canary_result_count: int | None = None + duration_seconds: float = 0.0 + slow_check: bool = False + slow_check_stage: str | None = None def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -163,15 +205,28 @@ def to_dict(self) -> dict[str, Any]: def _default_ps_output() -> str: - result = subprocess.run(["ps", "axo", "pid=,command="], text=True, capture_output=True, check=False) - return result.stdout + try: + result = subprocess.run( + ["ps", "axo", "pid=,command="], + text=True, + capture_output=True, + check=False, + timeout=5, + ) + return result.stdout + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" def _default_command_runner(args: list[str]) -> subprocess.CompletedProcess[str]: try: - return subprocess.run(args, text=True, capture_output=True, check=False) + return subprocess.run(args, text=True, capture_output=True, check=False, timeout=5) except FileNotFoundError as exc: return subprocess.CompletedProcess(args=args, returncode=127, stdout="", stderr=str(exc)) + except subprocess.TimeoutExpired as exc: + return subprocess.CompletedProcess( + args=args, returncode=124, stdout=exc.stdout or "", stderr="command timed out" + ) def _command_returncode(result: Any) -> int: @@ -196,6 +251,28 @@ def _kickstart(label: str, command_runner: CommandRunner) -> str: return f"kickstart:{label}" +def _launchd_process_state(label: str, command_runner: CommandRunner) -> tuple[int, str] | None: + launchd_result = command_runner(["launchctl", "print", _launchd_target(label)]) + if _command_returncode(launchd_result) != 0: + return None + match = re.search(r"\bpid\s*=\s*(\d+)\b", _command_stdout(launchd_result)) + if match is None: + return None + pid = int(match.group(1)) + ps_result = command_runner(["ps", "-o", "state=", "-p", str(pid)]) + if _command_returncode(ps_result) != 0: + return None + process_state = _command_stdout(ps_result).strip().splitlines() + if not process_state: + return None + return pid, process_state[0].strip() + + +def _state_is_uninterruptible(process_state: str) -> bool: + normalized = process_state.upper() + return "U" in normalized or normalized.startswith("D") + + def _bootstrap_if_absent(label: str, plist_path: Path, command_runner: CommandRunner) -> str: loaded = _launchd_label_loaded(label, command_runner) if loaded is True: @@ -408,55 +485,59 @@ def _holder_message(holder: LockHolder) -> str: return f"holder pid={holder.pid} command={command} db_path={holder.db_path}" -def count_missing_embeddings(db_path: Path) -> int: +def _read_only_count( + db_path: Path, + sql: str, + *, + stage: str, + deadline_at: float | None = None, + monotonic_fn: Callable[[], float] = time.monotonic, +) -> int: uri = f"file:{db_path.expanduser()}?mode=ro" conn = sqlite3.connect(uri, uri=True, timeout=5) try: conn.execute("PRAGMA query_only = ON") - row = conn.execute( - """ - SELECT COUNT(*) - FROM chunks c - LEFT JOIN chunk_vectors_rowids r ON r.id = c.id - WHERE r.id IS NULL - AND c.content IS NOT NULL - AND c.content != '' - AND c.archived_at IS NULL - AND c.superseded_by IS NULL - AND c.aggregated_into IS NULL - AND COALESCE(c.archived, 0) = 0 - AND COALESCE(c.status, 'active') = 'active' - """ - ).fetchone() + if deadline_at is not None: + conn.set_progress_handler(lambda: int(monotonic_fn() >= deadline_at), 1_000) + try: + row = conn.execute(sql).fetchone() + except sqlite3.OperationalError as exc: + if deadline_at is not None and "interrupted" in str(exc).lower(): + raise HealthCheckDeadlineExceeded(f"health-check deadline exceeded during {stage}") from exc + raise return int(row[0] if row else 0) finally: conn.close() -def _enrichment_backlog(db_path: Path) -> int: - uri = f"file:{db_path.expanduser()}?mode=ro" - conn = sqlite3.connect(uri, uri=True, timeout=5) - try: - conn.execute("PRAGMA query_only = ON") - row = conn.execute( - """ - SELECT COUNT(*) - FROM chunks - WHERE enriched_at IS NULL - AND enrich_status IS NULL - AND COALESCE(char_count, length(content), 0) >= 50 - AND content IS NOT NULL - AND content != '' - AND archived_at IS NULL - AND superseded_by IS NULL - AND aggregated_into IS NULL - AND COALESCE(archived, 0) = 0 - AND COALESCE(status, 'active') = 'active' - """ - ).fetchone() - return int(row[0] if row else 0) - finally: - conn.close() +def count_missing_embeddings( + db_path: Path, + *, + deadline_at: float | None = None, + monotonic_fn: Callable[[], float] = time.monotonic, +) -> int: + return _read_only_count( + db_path, + MISSING_EMBEDDINGS_SQL, + stage="missing_embeddings", + deadline_at=deadline_at, + monotonic_fn=monotonic_fn, + ) + + +def _enrichment_backlog( + db_path: Path, + *, + deadline_at: float | None = None, + monotonic_fn: Callable[[], float] = time.monotonic, +) -> int: + return _read_only_count( + db_path, + ENRICHMENT_BACKLOG_SQL, + stage="enrichment_backlog", + deadline_at=deadline_at, + monotonic_fn=monotonic_fn, + ) def _load_state(path: Path) -> dict[str, Any]: @@ -626,11 +707,28 @@ def _apply_heals( ) continue if consecutive_failures >= threshold: - action = ( - _bootstrap_if_absent(label, plist_path, command_runner) - if issue_code in bootstrap_issue_codes - else _kickstart(label, command_runner) - ) + if issue_code in bootstrap_issue_codes: + action = _bootstrap_if_absent(label, plist_path, command_runner) + else: + process = _launchd_process_state(label, command_runner) + if process is not None and _state_is_uninterruptible(process[1]): + pid, process_state = process + action = f"heal_backoff:{label}:{issue_code}:pid={pid}:state={process_state}" + if action not in result.actions: + result.actions.append(action) + _emit_heal_event( + { + "_type": "heal_backoff", + "label": label, + "issue_code": issue_code, + "consecutive_failures": consecutive_failures, + "pid": pid, + "process_state": process_state, + **details, + } + ) + continue + action = _kickstart(label, command_runner) if action.startswith("bootstrap:"): tripped.discard(key) heal_failures.pop(key, None) @@ -694,12 +792,21 @@ def _pause_sentinel_state(config: HealthCheckConfig, now: datetime) -> tuple[dic return payload, not stale, stale -def _source_recent(config: HealthCheckConfig, now: datetime, window_seconds: int) -> bool: +def _source_recent( + config: HealthCheckConfig, + now: datetime, + window_seconds: int, + *, + deadline_at: float | None = None, + monotonic_fn: Callable[[], float] = time.monotonic, +) -> bool: import glob cutoff = now.timestamp() - window_seconds for pattern in config.source_jsonl_globs: for raw_path in glob.iglob(str(Path(pattern).expanduser()), recursive=True): + if deadline_at is not None and monotonic_fn() >= deadline_at: + raise HealthCheckDeadlineExceeded("health-check deadline exceeded during source_recent") try: if Path(raw_path).stat().st_mtime >= cutoff: return True @@ -761,7 +868,10 @@ def run_health_check( socket_request_fn: SocketRequestFn = send_brainbar_search_canary, command_runner: CommandRunner = _default_command_runner, now_fn: Callable[[], datetime] = lambda: datetime.now(UTC), + monotonic_fn: Callable[[], float] = time.monotonic, ) -> HealthCheckResult: + started_monotonic = monotonic_fn() + deadline_at = started_monotonic + max(1.0, config.max_duration_seconds) now = now_fn() result = HealthCheckResult(checked_at=now.isoformat(), ok=True) state = _load_state(config.state_path) @@ -781,6 +891,25 @@ def add_issue(code: str, severity: str, message: str) -> None: } ) + def finish_slow(stage: str, message: str) -> HealthCheckResult: + result.slow_check = True + result.slow_check_stage = stage + result.duration_seconds = max(0.0, monotonic_fn() - started_monotonic) + add_issue("slow_check", "critical", message) + state_payload: dict[str, Any] = dict(state) + state_payload["ts"] = now.isoformat() + state_payload["slow_check"] = True + state_payload["slow_check_stage"] = stage + state_payload["duration_seconds"] = result.duration_seconds + _write_state(config.state_path, state_payload) + result.ok = False + return result + + def deadline_reached(stage: str) -> HealthCheckResult | None: + if monotonic_fn() < deadline_at: + return None + return finish_slow(stage, f"health-check exceeded {config.max_duration_seconds:.0f}s during {stage}") + ps_output = ps_output_fn() lock_holder = _read_writer_lock_holder(config.db_path, ps_output) result.lock_holder = lock_holder @@ -801,13 +930,29 @@ def add_issue(code: str, severity: str, message: str) -> None: previous_missing = state.get("missing_vectors") result.previous_missing_vectors = int(previous_missing) if isinstance(previous_missing, int) else None try: - result.missing_vectors = count_missing_embeddings(config.db_path) + result.missing_vectors = count_missing_embeddings( + config.db_path, + deadline_at=deadline_at, + monotonic_fn=monotonic_fn, + ) + except HealthCheckDeadlineExceeded as exc: + return finish_slow("missing_embeddings", str(exc)) + except sqlite3.OperationalError as exc: + if "interrupted" in str(exc).lower(): + return finish_slow("missing_embeddings", "health-check deadline interrupted missing_embeddings") + add_issue( + "missing_embeddings_count_failed", + "critical", + f"could not count missing embeddings: {exc}", + ) except Exception as exc: add_issue( "missing_embeddings_count_failed", "critical", f"could not count missing embeddings: {exc}", ) + if slow_result := deadline_reached("missing_embeddings"): + return slow_result stalled_ticks = 0 if result.missing_vectors is not None: @@ -861,6 +1006,8 @@ def add_issue(code: str, severity: str, message: str) -> None: config.brainbar_daemon_label, _plist_for_label(config, config.brainbar_daemon_label), ) + if slow_result := deadline_reached("brain_search_canary"): + return slow_result if config.heal: for label in ( @@ -889,6 +1036,8 @@ def add_issue(code: str, severity: str, message: str) -> None: if loaded is False: add_issue(issue_code, "critical", message) heal_issue_labels[issue_code] = (label, _plist_for_label(config, label)) + if slow_result := deadline_reached("launchd_status"): + return slow_result pause_payload, pause_active, pause_stale = _pause_sentinel_state(config, now) if pause_stale: @@ -927,11 +1076,22 @@ def add_issue(code: str, severity: str, message: str) -> None: or (queue_oldest_age is not None and queue_oldest_age >= config.queue_page_oldest_seconds) ): _push_notification("BrainLayer queue backlog", f"queue_count={queue_count} queue_bytes={queue_bytes}") + if slow_result := deadline_reached("queue_stats"): + return slow_result watcher_health = _load_json(config.watcher_health_path) watcher_poll_count = watcher_health.get("poll_count") previous_watcher_poll_count = state.get("watcher_poll_count") - source_recent = _source_recent(config, now, config.max_offsets_age_seconds) + try: + source_recent = _source_recent( + config, + now, + config.max_offsets_age_seconds, + deadline_at=deadline_at, + monotonic_fn=monotonic_fn, + ) + except HealthCheckDeadlineExceeded as exc: + return finish_slow("source_recent", str(exc)) offsets_age = _path_age_seconds(config.offsets_path, now) watcher_health_age = _path_age_seconds(config.watcher_health_path, now) if ( @@ -956,7 +1116,22 @@ def add_issue(code: str, severity: str, message: str) -> None: previous_drain_total = state.get("drain_drained_total") drain_starved = _drain_health_has_sqlite_busy_signal(drain_health) try: - enrichment_backlog = _enrichment_backlog(config.db_path) + enrichment_backlog = _enrichment_backlog( + config.db_path, + deadline_at=deadline_at, + monotonic_fn=monotonic_fn, + ) + except HealthCheckDeadlineExceeded as exc: + return finish_slow("enrichment_backlog", str(exc)) + except sqlite3.OperationalError as exc: + if "interrupted" in str(exc).lower(): + return finish_slow("enrichment_backlog", "health-check deadline interrupted enrichment_backlog") + enrichment_backlog = 0 + add_issue( + "enrichment_backlog_count_failed", + "critical", + f"could not count enrichment backlog: {exc}", + ) except Exception as exc: enrichment_backlog = 0 add_issue( @@ -964,6 +1139,8 @@ def add_issue(code: str, severity: str, message: str) -> None: "critical", f"could not count enrichment backlog: {exc}", ) + if slow_result := deadline_reached("enrichment_backlog"): + return slow_result drain_liveness_issue = check_drain_liveness( drain_label=config.drain_label, drain_loaded=drain_loaded, @@ -1050,6 +1227,20 @@ def add_issue(code: str, severity: str, message: str) -> None: state_payload["lock_holder_held_ticks"] = 0 if pause_payload: state_payload["pause_sentinel"] = pause_payload + result.duration_seconds = max(0.0, monotonic_fn() - started_monotonic) + result.slow_check = result.duration_seconds >= config.max_duration_seconds + state_payload["duration_seconds"] = result.duration_seconds + state_payload["slow_check"] = result.slow_check + if result.slow_check: + result.slow_check_stage = "finalize" + state_payload["slow_check_stage"] = result.slow_check_stage + add_issue( + "slow_check", + "critical", + f"health-check exceeded {config.max_duration_seconds:.0f}s before state write", + ) + else: + state_payload.pop("slow_check_stage", None) _write_state(config.state_path, state_payload) result.ok = not result.issues return result diff --git a/tests/test_installable_build.py b/tests/test_installable_build.py index eed2f93e..4c1573cd 100644 --- a/tests/test_installable_build.py +++ b/tests/test_installable_build.py @@ -562,6 +562,11 @@ def test_packaged_launchd_installer_installs_tier0_watchdog_without_env_runner(t rendered = home / "Library" / "LaunchAgents" / "com.brainlayer.tier0-watchdog.plist" plist = plistlib.loads(rendered.read_bytes()) assert plist["ProgramArguments"] == ["/bin/sh", str(installed_script)] + assert plist["EnvironmentVariables"]["TIER0_STALE_SECONDS"] == "900" + assert plist["EnvironmentVariables"]["TIER0_REPEAT_ALERT_SECONDS"] == "1800" + assert plist["EnvironmentVariables"]["TIER0_ALERT_STATE_PATH"] == str( + home / ".local" / "share" / "brainlayer" / "tier0-watchdog-alert-state" + ) rendered_content = rendered.read_text(encoding="utf-8") assert "__TIER0_WATCHDOG_SCRIPT__" not in rendered_content assert "brainlayer-env-run" not in rendered_content diff --git a/tests/test_stability_health_check.py b/tests/test_stability_health_check.py index 91fefec1..1a559558 100644 --- a/tests/test_stability_health_check.py +++ b/tests/test_stability_health_check.py @@ -84,7 +84,7 @@ def _make_db(path: Path, *, total: int, vector_rows: int) -> None: enrich_status TEXT, char_count INTEGER ); - CREATE TABLE chunk_vectors_rowids (id TEXT PRIMARY KEY); + CREATE TABLE chunk_vectors_rowids (id TEXT PRIMARY KEY, chunk_id INTEGER); """ ) for index in range(total): @@ -202,6 +202,44 @@ def test_any_zero_backlog_batch_alarms_when_multiple_hotlanes_are_running(tmp_pa assert "com.brainlayer.hotlane-brainbar" in " ".join(kickstarts[0]) +def test_uninterruptible_launchd_process_backs_off_instead_of_kickstarting(tmp_path, monkeypatch): + db_path = tmp_path / "brainlayer.db" + state_path = tmp_path / "health-state.json" + _make_db(db_path, total=1, vector_rows=1) + commands: list[list[str]] = [] + events: list[dict] = [] + monkeypatch.setattr(health_check, "_emit_heal_event", events.append) + + def command_runner(args: list[str]): + commands.append(args) + if args[:2] == ["launchctl", "print"] and "hotlane-brainbar" in args[-1]: + return SimpleNamespace(returncode=0, stdout="pid = 123\n", stderr="") + if args[:3] == ["ps", "-o", "state="]: + return SimpleNamespace(returncode=0, stdout="UN\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + result = run_health_check( + HealthCheckConfig( + db_path=db_path, + state_path=state_path, + heal=True, + heal_min_consecutive_failures=1, + heal_circuit_breaker_limit=3, + ), + ps_output_fn=lambda: "123 /usr/bin/python scripts/hotlane_brainbar_daemon.py --interval 1 --backlog-batch 0\n", + socket_request_fn=_ok_canary, + command_runner=command_runner, + now_fn=lambda: datetime(2026, 7, 13, 9, 5, tzinfo=UTC), + ) + + assert not [command for command in commands if command[:3] == ["launchctl", "kickstart", "-k"]] + assert "heal_backoff:com.brainlayer.hotlane-brainbar:hotlane_backlog_disabled:pid=123:state=UN" in result.actions + assert any( + event.get("_type") == "heal_backoff" and event.get("pid") == 123 and event.get("process_state") == "UN" + for event in events + ) + + def test_missing_embeddings_not_draining_after_two_ticks(tmp_path): db_path = tmp_path / "brainlayer.db" state_path = tmp_path / "health-state.json" @@ -235,6 +273,82 @@ def test_missing_embeddings_not_draining_after_two_ticks(tmp_path): assert saved["stalled_ticks"] == 2 +def test_missing_embedding_query_scans_covering_id_indexes_before_chunk_payloads(tmp_path): + db_path = tmp_path / "brainlayer.db" + _make_db(db_path, total=5, vector_rows=3) + + with sqlite3.connect(db_path) as conn: + plan = conn.execute("EXPLAIN QUERY PLAN " + health_check.MISSING_EMBEDDINGS_SQL).fetchall() + + details = [str(row[-1]) for row in plan] + assert any("chunks USING COVERING INDEX" in detail for detail in details) + assert any("chunk_vectors_rowids USING COVERING INDEX" in detail for detail in details) + assert "SCAN c" not in details + + +def test_interrupted_missing_embedding_count_writes_slow_state_and_returns_early(tmp_path, monkeypatch): + db_path = tmp_path / "brainlayer.db" + state_path = tmp_path / "health-state.json" + _make_db(db_path, total=1, vector_rows=1) + canary_called = False + + def interrupted_count(_db_path, **_kwargs): + raise sqlite3.OperationalError("interrupted") + + def canary(*_args): + nonlocal canary_called + canary_called = True + return _ok_canary(*_args) + + monkeypatch.setattr(health_check, "count_missing_embeddings", interrupted_count) + + result = run_health_check( + HealthCheckConfig(db_path=db_path, state_path=state_path, max_duration_seconds=45), + ps_output_fn=lambda: "123 /usr/bin/python scripts/hotlane_brainbar_daemon.py --interval 1 --backlog-batch 4\n", + socket_request_fn=canary, + command_runner=lambda _args: SimpleNamespace(returncode=0, stdout="", stderr=""), + now_fn=lambda: datetime(2026, 7, 13, 9, 0, tzinfo=UTC), + ) + + saved = json.loads(state_path.read_text(encoding="utf-8")) + assert result.slow_check is True + assert result.slow_check_stage == "missing_embeddings" + assert saved["slow_check"] is True + assert saved["slow_check_stage"] == "missing_embeddings" + assert saved["ts"] == "2026-07-13T09:00:00+00:00" + assert canary_called is False + + +def test_missing_embedding_count_interrupts_at_internal_deadline(tmp_path, monkeypatch): + db_path = tmp_path / "brainlayer.db" + _make_db(db_path, total=1, vector_rows=0) + callbacks = [] + + class DeadlineConnection: + def execute(self, sql): + if sql == "PRAGMA query_only = ON": + return self + raise sqlite3.OperationalError("interrupted") + + def set_progress_handler(self, callback, _instructions): + callbacks.append(callback) + + def close(self): + return None + + monkeypatch.setattr(health_check.sqlite3, "connect", lambda *_args, **_kwargs: DeadlineConnection()) + + with pytest.raises(RuntimeError, match="deadline"): + health_check.count_missing_embeddings( + db_path, + deadline_at=1.0, + monotonic_fn=lambda: 2.0, + ) + + assert len(callbacks) == 1 + assert callbacks[0]() == 1 + + def test_lock_holder_wedge_flags_live_holder_when_drain_is_starved(tmp_path, monkeypatch): db_path = tmp_path / "brainlayer.db" state_path = tmp_path / "health-state.json" diff --git a/tests/test_tier0_drills.py b/tests/test_tier0_drills.py index f9a552ec..98e795d0 100644 --- a/tests/test_tier0_drills.py +++ b/tests/test_tier0_drills.py @@ -25,6 +25,7 @@ class DrillResult: process: subprocess.CompletedProcess[str] events: list[str] tier0_log: str + alert_state: str def _write_executable(path: Path, contents: str) -> None: @@ -44,6 +45,9 @@ def _run_drill( curl_hangs: bool = False, osascript_hangs: bool = False, use_fake_wait_sleep: bool = False, + last_alert_epoch: int | None = None, + last_alert_reason: str = "", + repeat_alert_seconds: int = 1_800, ) -> DrillResult: fake_bin = tmp_path / "bin" fake_bin.mkdir() @@ -51,8 +55,15 @@ def _run_drill( state_path = tmp_path / "health-check-state.json" health_plist_path = tmp_path / "com.example.brainlayer-health-check.plist" tier0_log_path = tmp_path / "logs" / "tier0-watchdog.log" + alert_state_path = tmp_path / "tier0-watchdog-alert-state" health_plist_path.write_text("fixture\n", encoding="utf-8") + if last_alert_epoch is not None: + alert_state_path.write_text( + f"{last_alert_epoch}\t{last_alert_reason}\n", + encoding="utf-8", + ) + if state_mtime is not None: state_path.write_text("{}\n", encoding="utf-8") @@ -117,6 +128,7 @@ def _run_drill( "FAKE_LAUNCHCTL_PRINT_EXIT": "0" if label_loaded else "113", "FAKE_STATE_MTIME": str(state_mtime or 0), "TIER0_ALERT_TIMEOUT_SECONDS": "1", + "TIER0_ALERT_STATE_PATH": str(alert_state_path), "TIER0_CURL": str(fake_bin / "curl"), "TIER0_DOMAIN": DOMAIN, "TIER0_DRILL_EVENTS": str(events_path), @@ -126,6 +138,7 @@ def _run_drill( "TIER0_LOG_PATH": str(tier0_log_path), "TIER0_NOTIFY_ENDPOINT": NOTIFY_ENDPOINT, "TIER0_NOTIFY_TIMEOUT_SECONDS": "1", + "TIER0_REPEAT_ALERT_SECONDS": str(repeat_alert_seconds), "TIER0_NOW_EPOCH": str(NOW_EPOCH), "TIER0_OSASCRIPT": str(fake_bin / "osascript"), "TIER0_SLEEP": str(fake_bin / "wait-sleep") if use_fake_wait_sleep else "/bin/sleep", @@ -143,7 +156,8 @@ def _run_drill( ) events = events_path.read_text(encoding="utf-8").splitlines() if events_path.exists() else [] tier0_log = tier0_log_path.read_text(encoding="utf-8") if tier0_log_path.exists() else "" - return DrillResult(process=process, events=events, tier0_log=tier0_log) + alert_state = alert_state_path.read_text(encoding="utf-8") if alert_state_path.exists() else "" + return DrillResult(process=process, events=events, tier0_log=tier0_log, alert_state=alert_state) def _assert_alert_contract(result: DrillResult) -> None: @@ -188,6 +202,24 @@ def test_d2_stale_state_alerts_before_direct_kickstart(tmp_path: Path) -> None: _assert_alert_contract(result) assert not any(event.startswith("launchctl:bootstrap ") for event in result.events) assert f"state_stale age={STALE_SECONDS + 1}s threshold={STALE_SECONDS}s" in result.tier0_log + assert result.alert_state == f"{NOW_EPOCH}\tstate_stale\n" + + +def test_repeat_stale_alert_is_suppressed_during_cooldown_but_recovery_still_runs(tmp_path: Path) -> None: + result = _run_drill( + tmp_path, + label_loaded=True, + state_mtime=NOW_EPOCH - STALE_SECONDS - 1, + last_alert_epoch=NOW_EPOCH - 300, + last_alert_reason="state_stale", + ) + + assert result.process.returncode == 1 + assert not any(event.startswith("osascript:") for event in result.events) + assert not any(event.startswith("curl:") for event in result.events) + assert result.tier0_log == "" + assert any(event == f"launchctl:kickstart -k {DOMAIN}/{LABEL}" for event in result.events) + assert result.alert_state == f"{NOW_EPOCH - 300}\tstate_stale\n" def test_d3_hanging_notify_endpoint_cannot_suppress_local_alert_or_heal(tmp_path: Path) -> None: @@ -216,6 +248,19 @@ def test_d4_loaded_label_and_fresh_state_do_nothing(tmp_path: Path) -> None: assert result.tier0_log == "" +def test_fresh_state_resets_prior_incident_alert_cooldown(tmp_path: Path) -> None: + result = _run_drill( + tmp_path, + label_loaded=True, + state_mtime=NOW_EPOCH - 60, + last_alert_epoch=NOW_EPOCH - 300, + last_alert_reason="state_stale", + ) + + assert result.process.returncode == 0 + assert result.alert_state == "0\tok\n" + + def test_missing_state_alerts_and_kickstarts_without_bootstrap(tmp_path: Path) -> None: result = _run_drill(tmp_path, label_loaded=True, state_mtime=None) @@ -257,6 +302,11 @@ def test_tier0_launchagent_uses_bin_sh_without_python_wrapper() -> None: assert plist["ProgramArguments"] == ["/bin/sh", "__TIER0_WATCHDOG_SCRIPT__"] assert plist["StartInterval"] == 300 assert plist["RunAtLoad"] is True + assert plist["EnvironmentVariables"]["TIER0_STALE_SECONDS"] == "900" + assert plist["EnvironmentVariables"]["TIER0_REPEAT_ALERT_SECONDS"] == "1800" + assert plist["EnvironmentVariables"]["TIER0_ALERT_STATE_PATH"] == ( + "__HOME__/.local/share/brainlayer/tier0-watchdog-alert-state" + ) args = " ".join(plist["ProgramArguments"]) assert "ENV_RUN" not in args assert "PYTHON" not in args.upper() From ecff1360def38c4d61bea71775c6c674b510b1ec Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 13 Jul 2026 13:09:39 +0300 Subject: [PATCH 2/3] fix: harden health diagnostics --- docs/operations/brainlayer-health-check.md | 7 +- scripts/tier0-watchdog.sh | 5 ++ src/brainlayer/health_check.py | 88 +++++++++++++++------- tests/test_stability_health_check.py | 77 +++++++++++++++++++ tests/test_tier0_drills.py | 18 ++++- 5 files changed, 165 insertions(+), 30 deletions(-) diff --git a/docs/operations/brainlayer-health-check.md b/docs/operations/brainlayer-health-check.md index 76e6aa05..a3f7371a 100644 --- a/docs/operations/brainlayer-health-check.md +++ b/docs/operations/brainlayer-health-check.md @@ -39,9 +39,10 @@ The command writes the latest successfully measured missing-vector count to `~/.local/share/brainlayer/health-check-state.json`; a timed-out tick preserves the previous count and marks the state as slow. -The independent Tier-0 watchdog treats that state as stale after 900 seconds. -Its first alert is immediate; repeat alerts for the same failure class are -suppressed for 1,800 seconds while recovery attempts continue. +The independent Tier-0 watchdog treats `slow_check: true` as unhealthy even +when the state file was just refreshed, and treats any state as stale after 900 +seconds. Its first alert is immediate; repeat alerts for the same failure class +are suppressed for 1,800 seconds while recovery attempts continue. ## Logs diff --git a/scripts/tier0-watchdog.sh b/scripts/tier0-watchdog.sh index 61407ec1..e12b8232 100755 --- a/scripts/tier0-watchdog.sh +++ b/scripts/tier0-watchdog.sh @@ -12,6 +12,7 @@ TIER0_ID=${TIER0_ID:-/usr/bin/id} TIER0_SLEEP=${TIER0_SLEEP:-/bin/sleep} TIER0_DIRNAME=${TIER0_DIRNAME:-/usr/bin/dirname} TIER0_MKDIR=${TIER0_MKDIR:-/bin/mkdir} +TIER0_GREP=${TIER0_GREP:-/usr/bin/grep} TIER0_LABEL=${TIER0_LABEL:-com.brainlayer.health-check} if [ -z "${TIER0_DOMAIN:-}" ]; then @@ -193,6 +194,10 @@ if [ "$label_loaded" -eq 1 ]; then fi ;; esac + if [ -z "$failure_reason" ] && "$TIER0_GREP" -Eq '"slow_check"[[:space:]]*:[[:space:]]*true' "$TIER0_STATE_PATH" 2>/dev/null; then + failure_reason=state_slow_check + failure_key=state_slow_check + fi fi fi diff --git a/src/brainlayer/health_check.py b/src/brainlayer/health_check.py index 95d3eb1b..e1bb402f 100644 --- a/src/brainlayer/health_check.py +++ b/src/brainlayer/health_check.py @@ -10,6 +10,7 @@ import sqlite3 import subprocess import sys +import threading import time from dataclasses import asdict, dataclass, field, replace from datetime import UTC, datetime @@ -204,7 +205,7 @@ def to_dict(self) -> dict[str, Any]: SocketRequestFn = Callable[[Path, str, float], dict[str, Any]] -def _default_ps_output() -> str: +def _default_ps_output() -> str | None: try: result = subprocess.run( ["ps", "axo", "pid=,command="], @@ -213,9 +214,9 @@ def _default_ps_output() -> str: check=False, timeout=5, ) - return result.stdout + return result.stdout if result.returncode == 0 else None except (FileNotFoundError, subprocess.TimeoutExpired): - return "" + return None def _default_command_runner(args: list[str]) -> subprocess.CompletedProcess[str]: @@ -802,17 +803,41 @@ def _source_recent( ) -> bool: import glob - cutoff = now.timestamp() - window_seconds - for pattern in config.source_jsonl_globs: - for raw_path in glob.iglob(str(Path(pattern).expanduser()), recursive=True): - if deadline_at is not None and monotonic_fn() >= deadline_at: - raise HealthCheckDeadlineExceeded("health-check deadline exceeded during source_recent") - try: - if Path(raw_path).stat().st_mtime >= cutoff: - return True - except OSError: - continue - return False + def scan() -> bool: + cutoff = now.timestamp() - window_seconds + for pattern in config.source_jsonl_globs: + for raw_path in glob.iglob(str(Path(pattern).expanduser()), recursive=True): + try: + if Path(raw_path).stat().st_mtime >= cutoff: + return True + except OSError: + continue + return False + + if deadline_at is None: + return scan() + + remaining = deadline_at - monotonic_fn() + if remaining <= 0: + raise HealthCheckDeadlineExceeded("health-check deadline exceeded during source_recent") + + completed = threading.Event() + outcome: dict[str, Any] = {} + + def bounded_scan() -> None: + try: + outcome["value"] = scan() + except Exception as exc: + outcome["error"] = exc + finally: + completed.set() + + threading.Thread(target=bounded_scan, name="brainlayer-health-source-scan", daemon=True).start() + if not completed.wait(timeout=remaining): + raise HealthCheckDeadlineExceeded("health-check deadline exceeded during source_recent") + if error := outcome.get("error"): + raise error + return bool(outcome.get("value", False)) def _queue_stats(queue_dir: Path, now: datetime) -> tuple[int, int, float | None]: @@ -864,7 +889,7 @@ def _plist_for_label(config: HealthCheckConfig, label: str) -> Path: def run_health_check( config: HealthCheckConfig, *, - ps_output_fn: Callable[[], str] = _default_ps_output, + ps_output_fn: Callable[[], str | None] = _default_ps_output, socket_request_fn: SocketRequestFn = send_brainbar_search_canary, command_runner: CommandRunner = _default_command_runner, now_fn: Callable[[], datetime] = lambda: datetime.now(UTC), @@ -911,21 +936,32 @@ def deadline_reached(stage: str) -> HealthCheckResult | None: return finish_slow(stage, f"health-check exceeded {config.max_duration_seconds:.0f}s during {stage}") ps_output = ps_output_fn() - lock_holder = _read_writer_lock_holder(config.db_path, ps_output) - result.lock_holder = lock_holder - hotlane_processes = parse_hotlane_processes(ps_output) - result.hotlane_running = bool(hotlane_processes) - if not hotlane_processes: - add_issue("hotlane_dead", "critical", "hotlane BrainBar embedding daemon is not running") - heal_issue_labels["hotlane_dead"] = (config.hotlane_label, _plist_for_label(config, config.hotlane_label)) + lock_holder = None + if ps_output is None: + add_issue( + "process_snapshot_failed", + "critical", + "could not read the process table; daemon liveness and writer ownership were not evaluated", + ) else: - result.backlog_batch = min(process.backlog_batch for process in hotlane_processes) - if any(process.backlog_batch <= 0 for process in hotlane_processes): - add_issue("hotlane_backlog_disabled", "critical", "--backlog-batch is 0; embeddings will not drain") - heal_issue_labels["hotlane_backlog_disabled"] = ( + lock_holder = _read_writer_lock_holder(config.db_path, ps_output) + result.lock_holder = lock_holder + hotlane_processes = parse_hotlane_processes(ps_output) + result.hotlane_running = bool(hotlane_processes) + if not hotlane_processes: + add_issue("hotlane_dead", "critical", "hotlane BrainBar embedding daemon is not running") + heal_issue_labels["hotlane_dead"] = ( config.hotlane_label, _plist_for_label(config, config.hotlane_label), ) + else: + result.backlog_batch = min(process.backlog_batch for process in hotlane_processes) + if any(process.backlog_batch <= 0 for process in hotlane_processes): + add_issue("hotlane_backlog_disabled", "critical", "--backlog-batch is 0; embeddings will not drain") + heal_issue_labels["hotlane_backlog_disabled"] = ( + config.hotlane_label, + _plist_for_label(config, config.hotlane_label), + ) previous_missing = state.get("missing_vectors") result.previous_missing_vectors = int(previous_missing) if isinstance(previous_missing, int) else None diff --git a/tests/test_stability_health_check.py b/tests/test_stability_health_check.py index 1a559558..775e039c 100644 --- a/tests/test_stability_health_check.py +++ b/tests/test_stability_health_check.py @@ -349,6 +349,83 @@ def close(self): assert callbacks[0]() == 1 +def test_source_recent_returns_at_deadline_when_glob_iteration_stalls(tmp_path, monkeypatch): + import glob + import threading + import time + + release_glob = threading.Event() + + def stalled_glob(*_args, **_kwargs): + release_glob.wait() + yield str(tmp_path / "event.jsonl") + + monkeypatch.setattr(glob, "iglob", stalled_glob) + outcome: dict[str, object] = {} + + def call_source_recent() -> None: + try: + health_check._source_recent( + HealthCheckConfig(source_jsonl_globs=(str(tmp_path / "**" / "*.jsonl"),)), + datetime(2026, 7, 13, 9, 0, tzinfo=UTC), + 60, + deadline_at=time.monotonic() + 0.05, + ) + except Exception as exc: + outcome["error"] = exc + + caller = threading.Thread(target=call_source_recent) + caller.start() + caller.join(timeout=0.25) + returned_before_release = not caller.is_alive() + release_glob.set() + caller.join(timeout=1) + + assert returned_before_release is True + assert isinstance(outcome.get("error"), health_check.HealthCheckDeadlineExceeded) + + +def test_ps_snapshot_failure_does_not_heal_a_running_daemon_as_dead(tmp_path): + db_path = tmp_path / "brainlayer.db" + state_path = tmp_path / "health-state.json" + _make_db(db_path, total=1, vector_rows=1) + commands: list[list[str]] = [] + + def command_runner(args): + commands.append(args) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + result = run_health_check( + HealthCheckConfig( + db_path=db_path, + state_path=state_path, + heal=True, + heal_min_consecutive_failures=1, + ), + ps_output_fn=lambda: None, + socket_request_fn=_ok_canary, + command_runner=command_runner, + now_fn=lambda: datetime(2026, 7, 13, 9, 0, tzinfo=UTC), + ) + + issue_codes = {issue.code for issue in result.issues} + assert "process_snapshot_failed" in issue_codes + assert "hotlane_dead" not in issue_codes + assert not any( + args[:3] == ["launchctl", "kickstart", "-k"] and args[-1].endswith("/com.brainlayer.hotlane") + for args in commands + ) + + +def test_default_ps_output_marks_timeout_as_unavailable(monkeypatch): + def timeout(*_args, **_kwargs): + raise health_check.subprocess.TimeoutExpired(cmd=["ps"], timeout=5) + + monkeypatch.setattr(health_check.subprocess, "run", timeout) + + assert health_check._default_ps_output() is None + + def test_lock_holder_wedge_flags_live_holder_when_drain_is_starved(tmp_path, monkeypatch): db_path = tmp_path / "brainlayer.db" state_path = tmp_path / "health-state.json" diff --git a/tests/test_tier0_drills.py b/tests/test_tier0_drills.py index 98e795d0..a52d33a2 100644 --- a/tests/test_tier0_drills.py +++ b/tests/test_tier0_drills.py @@ -48,6 +48,7 @@ def _run_drill( last_alert_epoch: int | None = None, last_alert_reason: str = "", repeat_alert_seconds: int = 1_800, + state_contents: str = "{}\n", ) -> DrillResult: fake_bin = tmp_path / "bin" fake_bin.mkdir() @@ -65,7 +66,7 @@ def _run_drill( ) if state_mtime is not None: - state_path.write_text("{}\n", encoding="utf-8") + state_path.write_text(state_contents, encoding="utf-8") _write_executable( fake_bin / "launchctl", @@ -280,6 +281,21 @@ def test_future_state_mtime_alerts_and_kickstarts(tmp_path: Path) -> None: assert "state_mtime_future offset=60s" in result.tier0_log +def test_fresh_slow_check_state_alerts_and_kickstarts(tmp_path: Path) -> None: + result = _run_drill( + tmp_path, + label_loaded=True, + state_mtime=NOW_EPOCH - 60, + state_contents='{"slow_check": true, "slow_check_stage": "missing_embeddings"}\n', + ) + + assert result.process.returncode == 1, result.process.stdout + result.process.stderr + _assert_alert_contract(result) + assert not any(event.startswith("launchctl:bootstrap ") for event in result.events) + assert "state_slow_check" in result.tier0_log + assert result.alert_state == f"{NOW_EPOCH}\tstate_slow_check\n" + + def test_alert_fanout_uses_one_shared_deadline(tmp_path: Path) -> None: result = _run_drill( tmp_path, From 126893ab549c39ecaa5849c90114e5cbd7abc11f Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 13 Jul 2026 13:25:33 +0300 Subject: [PATCH 3/3] fix: handle unavailable doctor process snapshots --- src/brainlayer/doctor.py | 17 ++++++++++++----- tests/test_doctor.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/brainlayer/doctor.py b/src/brainlayer/doctor.py index 63083c3a..633d90e7 100644 --- a/src/brainlayer/doctor.py +++ b/src/brainlayer/doctor.py @@ -387,7 +387,7 @@ def _check_brainbar_version_consistency( def run_doctor( config: DoctorConfig, *, - ps_output_fn: Callable[[], str] = _default_ps_output, + ps_output_fn: Callable[[], str | None] = _default_ps_output, command_runner: CommandRunner = _default_command_runner, now_fn: Callable[[], datetime] = lambda: datetime.now(UTC), ) -> DoctorResult: @@ -500,10 +500,17 @@ def warning(code: str, message: str, **details: Any) -> None: command_runner=command_runner, ) - hotlane_processes = parse_hotlane_processes(ps_output_fn()) - result.hotlane_running = bool(hotlane_processes) - if not hotlane_processes: - fatal("hotlane_dead", "hot-lane embedding process is not running") + ps_output = ps_output_fn() + if ps_output is None: + fatal( + "process_snapshot_failed", + "could not read the process table; hot-lane daemon liveness was not evaluated", + ) + else: + hotlane_processes = parse_hotlane_processes(ps_output) + result.hotlane_running = bool(hotlane_processes) + if not hotlane_processes: + fatal("hotlane_dead", "hot-lane embedding process is not running") if result.enrichment_backlog and result.enrichment_backlog > 0: warning( diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 0e9f9307..9ad1ab4e 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -343,6 +343,26 @@ def test_run_doctor_exits_zero_on_healthy_fixture(tmp_path): assert not [issue for issue in result.issues if issue.severity == "fatal"] +def test_run_doctor_reports_unavailable_process_snapshot_without_false_daemon_death(tmp_path): + from brainlayer.doctor import run_doctor + + db_path = tmp_path / "healthy.db" + _build_db(db_path) + + result = run_doctor( + _doctor_config(tmp_path, db_path), + ps_output_fn=lambda: None, + command_runner=_loaded_launchctl, + now_fn=lambda: NOW, + ) + + issue_codes = {issue.code for issue in result.issues} + assert result.exit_code == 1 + assert result.ok is False + assert "process_snapshot_failed" in issue_codes + assert "hotlane_dead" not in issue_codes + + def test_run_doctor_can_skip_roundtrip_probe_for_bounded_status_gate(tmp_path, monkeypatch): import brainlayer.doctor as doctor