From be360dc6413faee1cd1be9399d274ab5a35fc9b9 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sun, 28 Jun 2026 20:49:29 -0600 Subject: [PATCH] feat(home): surface thinking readiness in the home health glance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a third signal to the home verdict-line glance: when sol has no working way to think (its core generate path is genuinely down, local or cloud), surface one amber chip "sol needs a way to think" linking to /app/thinking/. Capability-framed copy only — the readiness summary and provider strings never cross into the glance; only a bool does. The bool comes from a new cached, non-false-alarming helper that reads build_readiness_snapshot()["interfaces"]["generate"], biases to silence on any uncertainty (transient startup, unknown/ready status, empty snapshot, or any error), and caches in awareness state on a 300s TTL — mirroring the system.py version cache so the pulse hot path never probes. Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/apps/home/health_glance.py | 10 ++ solstone/apps/home/routes.py | 7 +- solstone/apps/home/thinking_readiness.py | 66 ++++++++++ tests/test_home_health_glance.py | 65 ++++++++++ tests/test_home_thinking_readiness.py | 147 +++++++++++++++++++++++ tests/test_home_yesterdays_processing.py | 15 +++ 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 solstone/apps/home/thinking_readiness.py create mode 100644 tests/test_home_thinking_readiness.py diff --git a/solstone/apps/home/health_glance.py b/solstone/apps/home/health_glance.py index 8d6ff4df6..5ff76d238 100644 --- a/solstone/apps/home/health_glance.py +++ b/solstone/apps/home/health_glance.py @@ -17,6 +17,7 @@ def build_health_glance( capture_health: Any, pipeline_status: Any, last_observe_relative: str | None, + thinking_blocked: bool = False, ) -> dict: issues = [] @@ -32,6 +33,15 @@ def build_health_glance( if pipeline_issue is not None: issues.append(pipeline_issue) + if thinking_blocked: + issues.append( + { + "text": "sol needs a way to think", + "severity": "amber", + "href": "/app/thinking/", + } + ) + if issues: severity = ( "red" if any(issue["severity"] == "red" for issue in issues) else "amber" diff --git a/solstone/apps/home/routes.py b/solstone/apps/home/routes.py index 44cea4cc6..ae1bad621 100644 --- a/solstone/apps/home/routes.py +++ b/solstone/apps/home/routes.py @@ -19,6 +19,7 @@ from flask import Blueprint, jsonify, render_template from solstone.apps.home.health_glance import build_health_glance from solstone.apps.home.needs_you import classify_needs_you +from solstone.apps.home.thinking_readiness import _thinking_blocked from solstone.convey.apps import _resolve_attention from solstone.convey.bridge import get_cached_state from solstone.convey.utils import DATE_RE, format_date, relative_time @@ -988,8 +989,12 @@ def _build_pulse_context() -> dict[str, Any]: summary = read_steward_summary() if summary: pipeline_status = {**pipeline_status, **summary} + thinking_blocked = _thinking_blocked() health_glance = build_health_glance( - capture_health, pipeline_status, last_observe_relative + capture_health, + pipeline_status, + last_observe_relative, + thinking_blocked=thinking_blocked, ) yesterday_processing = _summarize_yesterday_processing(yesterday, journal_age_days) diff --git a/solstone/apps/home/thinking_readiness.py b/solstone/apps/home/thinking_readiness.py new file mode 100644 index 000000000..355f1d116 --- /dev/null +++ b/solstone/apps/home/thinking_readiness.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Cached thinking-readiness signal for the home health glance.""" + +from __future__ import annotations + +import logging +import time + +from solstone.convey.provider_readiness import _STARTUP_REASON_CODES +from solstone.convey.readiness_snapshot import build_readiness_snapshot +from solstone.think.awareness import get_current, update_state +from solstone.think.journal_io.errors import LockTimeout + +logger = logging.getLogger(__name__) + +_THINKING_READINESS_CACHE_TTL_S = 300 # seconds + + +def _generate_path_blocked(generate_view: dict | None) -> bool: + """True only when the core generate path is genuinely down (not transient). + + Drives off the `generate` interface view alone — never summary.severity + (which under-fires on a down sole path) and never cogitate/context routes. + Biases to False on any uncertainty (missing view, unknown/ready status, + transient local-model startup). + """ + if not generate_view: + return False + if generate_view.get("status") not in {"blocked", "unhealthy"}: + return False + return generate_view.get("reason_code") not in _STARTUP_REASON_CODES + + +def _thinking_blocked() -> bool: + """Return whether sol has no working way to think, from a short TTL cache. + + Never probes on the hot path: reads the cached bool when fresh, otherwise + recomputes once from build_readiness_snapshot() and writes the result back + through the awareness owner (update_state). Biases to False on any error so + a transient hiccup never false-alarms the home glance. + """ + now = time.time() + cached = get_current().get("thinking_readiness", {}) + checked_at = cached.get("checked_at", 0) + if now - checked_at < _THINKING_READINESS_CACHE_TTL_S and "blocked" in cached: + return bool(cached["blocked"]) + + try: + snapshot = build_readiness_snapshot() + generate_view = snapshot.get("interfaces", {}).get("generate") + blocked = _generate_path_blocked(generate_view) + except Exception: + logger.warning( + "thinking-readiness recompute failed; treating as not blocked", + exc_info=True, + ) + return False + + try: + update_state("thinking_readiness", {"blocked": blocked, "checked_at": now}) + except LockTimeout: + logger.debug("thinking-readiness cache write skipped (awareness busy)") + + return blocked diff --git a/tests/test_home_health_glance.py b/tests/test_home_health_glance.py index 5cd9dd0a3..e03a81872 100644 --- a/tests/test_home_health_glance.py +++ b/tests/test_home_health_glance.py @@ -14,6 +14,13 @@ BANNED_RE = re.compile( r"\b(watch|capture|record|monitor|track|collect)\b", re.IGNORECASE, ) +EXTENDED_BANNED_RE = re.compile( + r"\b(" + r"capture|watch|record|monitor|track|collect|credentials|api|key|gemini|" + r"cloud|model|provider|llm|configure" + r")\b", + re.IGNORECASE, +) HEALTH_DETAIL_HREF = "/app/health#focus=recent-errors&day=today" @@ -196,6 +203,50 @@ def test_pipeline_health_actions_route_to_recent_errors(pipeline_status): assert result["issues"][0]["href"] == HEALTH_DETAIL_HREF +def test_thinking_blocked_alone_returns_amber_attention_issue(): + result = build_health_glance(_active_capture(), None, None, thinking_blocked=True) + + assert result["issues"] == [ + { + "text": "sol needs a way to think", + "severity": "amber", + "href": "/app/thinking/", + } + ] + assert result["verdict"] == "attention" + assert result["severity"] == "amber" + assert result["headline"] == "1 thing needs your attention" + + +def test_thinking_blocked_combines_with_red_capture_issue(): + result = build_health_glance( + _degraded_capture("fedora"), None, None, thinking_blocked=True + ) + + assert len(result["issues"]) == 2 + assert result["verdict"] == "attention" + assert result["severity"] == "red" + assert result["headline"] == "2 things need your attention" + assert { + "text": "sol needs a way to think", + "severity": "amber", + "href": "/app/thinking/", + } in result["issues"] + assert any(issue["severity"] == "red" for issue in result["issues"]) + + +def test_thinking_not_blocked_keeps_ok_glance(): + result = build_health_glance( + _active_capture(), None, "5m ago", thinking_blocked=False + ) + + assert result["verdict"] == "ok" + assert result["headline"] == "everything's working" + assert all( + issue["text"] != "sol needs a way to think" for issue in result["issues"] + ) + + def test_all_issue_and_cta_hrefs_are_local_paths(): states = [ build_health_glance(_degraded_capture("fedora"), None, None), @@ -245,6 +296,7 @@ def test_owner_facing_strings_use_allowed_terms(): None, ), build_health_glance(_active_capture(), None, "5m ago"), + build_health_glance(_active_capture(), None, None, thinking_blocked=True), build_health_glance({"status": "no_observers", "observers": []}, None, None), build_health_glance({"status": "unknown", "observers": []}, None, None), build_health_glance( @@ -263,3 +315,16 @@ def test_owner_facing_strings_use_allowed_terms(): strings.extend(issue["text"] for issue in state["issues"]) for text in strings: assert BANNED_RE.findall(text) == [] + + +def test_thinking_blocked_chip_uses_owner_copy() -> None: + result = build_health_glance(_active_capture(), None, None, thinking_blocked=True) + chip = next( + issue + for issue in result["issues"] + if issue["text"] == "sol needs a way to think" + ) + + assert chip["text"] == "sol needs a way to think" + assert chip["text"] == chip["text"].lower() + assert EXTENDED_BANNED_RE.findall(chip["text"]) == [] diff --git a/tests/test_home_thinking_readiness.py b/tests/test_home_thinking_readiness.py new file mode 100644 index 000000000..fbbd944f1 --- /dev/null +++ b/tests/test_home_thinking_readiness.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + +from solstone.apps.home.thinking_readiness import ( + _generate_path_blocked, + _thinking_blocked, +) +from solstone.convey.readiness_snapshot import unavailable_snapshot +from solstone.think.journal_io.errors import LockTimeout + + +@pytest.mark.parametrize( + ("generate_view", "expected"), + [ + ({"status": "blocked", "reason_code": "provider_key_missing"}, True), + ( + {"status": "unhealthy", "reason_code": "local_endpoint_unreachable"}, + True, + ), + ({"status": "unhealthy", "reason_code": "local_server_unhealthy"}, True), + ({"status": "blocked", "reason_code": "provider_quota_exceeded"}, True), + ({"status": "ready", "reason_code": None}, False), + ({"status": "unknown", "reason_code": None}, False), + ({"status": "unhealthy", "reason_code": "local_model_installing"}, False), + ({"status": "unhealthy", "reason_code": "local_model_loading"}, False), + ], +) +def test_generate_path_blocked_from_generate_interface( + generate_view: dict, + expected: bool, +) -> None: + assert _generate_path_blocked(generate_view) is expected + + +def test_generate_path_blocked_ignores_cogitate_interface() -> None: + snapshot = { + "interfaces": { + "generate": {"status": "ready", "reason_code": None}, + "cogitate": { + "status": "blocked", + "reason_code": "provider_key_missing", + }, + } + } + + assert _generate_path_blocked(snapshot["interfaces"]["generate"]) is False + + +def test_generate_path_blocked_biases_false_when_unavailable() -> None: + assert ( + _generate_path_blocked(unavailable_snapshot()["interfaces"].get("generate")) + is False + ) + + +def test_thinking_blocked_uses_fresh_cache( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + from solstone.think.awareness import update_state + + update_state("thinking_readiness", {"blocked": True, "checked_at": time.time()}) + + with patch( + "solstone.apps.home.thinking_readiness.build_readiness_snapshot" + ) as mock_snapshot: + assert _thinking_blocked() is True + + mock_snapshot.assert_not_called() + + +def test_thinking_blocked_recomputes_and_writes_cache( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + blocked_snapshot = { + "interfaces": { + "generate": { + "status": "blocked", + "reason_code": "provider_key_missing", + } + } + } + + with patch( + "solstone.apps.home.thinking_readiness.build_readiness_snapshot", + return_value=blocked_snapshot, + ) as mock_snapshot: + assert _thinking_blocked() is True + + mock_snapshot.assert_called_once_with() + + from solstone.think.awareness import get_current + + cached = get_current()["thinking_readiness"] + assert cached["blocked"] is True + assert "checked_at" in cached + + +def test_thinking_blocked_returns_computed_value_when_cache_write_is_busy( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + blocked_snapshot = { + "interfaces": { + "generate": { + "status": "blocked", + "reason_code": "provider_key_missing", + } + } + } + + with ( + patch( + "solstone.apps.home.thinking_readiness.build_readiness_snapshot", + return_value=blocked_snapshot, + ), + patch( + "solstone.apps.home.thinking_readiness.update_state", + side_effect=LockTimeout(Path("x"), 1.0), + ), + ): + assert _thinking_blocked() is True + + +def test_thinking_blocked_biases_false_on_recompute_exception( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + + with patch( + "solstone.apps.home.thinking_readiness.build_readiness_snapshot", + side_effect=Exception("boom"), + ): + assert _thinking_blocked() is False diff --git a/tests/test_home_yesterdays_processing.py b/tests/test_home_yesterdays_processing.py index 7dd269746..c0f09e737 100644 --- a/tests/test_home_yesterdays_processing.py +++ b/tests/test_home_yesterdays_processing.py @@ -201,6 +201,7 @@ def _patch_minimal_pulse_context(monkeypatch, pipeline_status): "solstone.apps.home.routes.read_steward_summary", lambda *a, **k: None, ) + monkeypatch.setattr("solstone.apps.home.routes._thinking_blocked", lambda: False) monkeypatch.setattr( "solstone.apps.home.routes._summarize_yesterday_processing", lambda yesterday, journal_age_days: { @@ -665,6 +666,7 @@ def test_build_pulse_context_includes_yesterday_processing(monkeypatch): "solstone.apps.home.routes._collect_activities", lambda today: [] ) monkeypatch.setattr("solstone.apps.home.routes.read_steward_health", lambda: None) + monkeypatch.setattr("solstone.apps.home.routes._thinking_blocked", lambda: False) monkeypatch.setattr( "solstone.apps.home.routes._summarize_yesterday_processing", lambda yesterday, journal_age_days: { @@ -722,3 +724,16 @@ def test_build_pulse_context_pipeline_status_enriched_with_summary(monkeypatch): assert ctx["pipeline_status"]["suggested_action"] == "open_health_detail" # The deterministic warning signal is preserved. assert ctx["pipeline_status"]["message"] == "Foo bar" + + +def test_build_pulse_context_surfaces_thinking_chip_when_blocked(monkeypatch): + _patch_minimal_pulse_context(monkeypatch, None) + monkeypatch.setattr("solstone.apps.home.routes._thinking_blocked", lambda: True) + + ctx = _build_pulse_context() + + assert { + "text": "sol needs a way to think", + "severity": "amber", + "href": "/app/thinking/", + } in ctx["health_glance"]["issues"] -- 2.51.2