diff --git a/docs/openapi/convey-clients.json b/docs/openapi/convey-clients.json index 327299663..3cd109991 100644 --- a/docs/openapi/convey-clients.json +++ b/docs/openapi/convey-clients.json @@ -96,6 +96,7 @@ "pl_revoked", "principal_entity_protected", "provider_key_missing", + "provider_validation_failed", "push_request_invalid", "raw_media_not_available", "recovery_key_mismatch", diff --git a/solstone/apps/speakers/status.py b/solstone/apps/speakers/status.py index 200c8ec32..28381c907 100644 --- a/solstone/apps/speakers/status.py +++ b/solstone/apps/speakers/status.py @@ -221,6 +221,7 @@ def _attribution_section() -> dict[str, Any]: try: data = json.loads(labels_file.read_text()) except Exception: + logger.warning("Failed to read speaker labels", exc_info=True) continue total_files += 1 for label in data.get("labels", []): diff --git a/solstone/apps/speakers/tests/test_status.py b/solstone/apps/speakers/tests/test_status.py index 074ea5cb6..c53b482bf 100644 --- a/solstone/apps/speakers/tests/test_status.py +++ b/solstone/apps/speakers/tests/test_status.py @@ -205,7 +205,7 @@ def test_status_embeddings_with_data(speakers_env): assert result["date_range"] == ["20240101", "20240102"] -def test_status_attribution_with_labels(speakers_env): +def test_status_attribution_with_labels(speakers_env, caplog): from solstone.apps.speakers.status import get_speakers_status env = speakers_env() @@ -227,8 +227,24 @@ def test_status_attribution_with_labels(speakers_env): }, ], ) + corrupt_path = env.create_speaker_labels( + "20240101", + "091000_300", + [ + { + "sentence_id": 1, + "speaker": "bob", + "confidence": "high", + "method": "voiceprint", + } + ], + ) + corrupt_path.write_text("{ invalid json }", encoding="utf-8") + + with caplog.at_level("WARNING", logger="solstone.apps.speakers.status"): + result = get_speakers_status(section="attribution") - result = get_speakers_status(section="attribution") + assert "Failed to read speaker labels" in caplog.text assert result["files"] == 1 assert result["labels"] == 2 assert result["by_confidence"]["high"] == 1 diff --git a/solstone/convey/apps.py b/solstone/convey/apps.py index 76cfa4054..99e573a41 100644 --- a/solstone/convey/apps.py +++ b/solstone/convey/apps.py @@ -5,6 +5,7 @@ from __future__ import annotations +import logging from dataclasses import dataclass from datetime import date @@ -16,6 +17,8 @@ from solstone.think.talent_runs import AgentFailure, read_unresolved_agent_failu from .icons import APP_LUCIDE_MAP, lucide_svg, resolve_facet_icon_svg +logger = logging.getLogger(__name__) + def _get_facets_data() -> list[dict]: """Get active facets data for templates.""" @@ -154,7 +157,7 @@ def _resolve_attention(awareness_current: dict) -> AttentionItem | None: context_lines=context, ) except Exception: - pass + logger.warning("failed to resolve chat bar cortex attention", exc_info=True) # P1: Recent import completion imports = awareness_current.get("imports", {}) @@ -326,7 +329,9 @@ def register_app_context(app: Flask, registry: AppRegistry) -> None: chat_bar_attention = {"placeholder_text": attention.placeholder_text} chat_bar_placeholder = _resolve_placeholder(awareness_current, day_count) except Exception: - pass # Default placeholder on any error + logger.warning( + "failed to resolve chat bar awareness context", exc_info=True + ) today = date.today().strftime("%Y%m%d") from solstone.convey.chat_stream import read_chat_events diff --git a/solstone/convey/reasons.py b/solstone/convey/reasons.py index 868368218..84ca9359d 100644 --- a/solstone/convey/reasons.py +++ b/solstone/convey/reasons.py @@ -119,6 +119,11 @@ SETTINGS_OPERATION_FAILED = Reason( "I couldn't save those settings.", 500, ) +PROVIDER_VALIDATION_FAILED = Reason( + "provider_validation_failed", + "I couldn't check that provider key.", + 500, +) CONVEY_OPERATION_FAILED = Reason( "convey_operation_failed", "I couldn't update the interface settings.", diff --git a/solstone/convey/root.py b/solstone/convey/root.py index de7410b8f..060006640 100644 --- a/solstone/convey/root.py +++ b/solstone/convey/root.py @@ -51,6 +51,7 @@ from .reasons import ( INVALID_CONFIG_VALUE, INVALID_OPERATION_FOR_STATE, PL_REVOKED, + PROVIDER_VALIDATION_FAILED, ) from .secure_listener import get_authorized_clients, start_secure_listener from .secure_listener.wsgi import CERTLESS_PAIR_ENDPOINTS @@ -273,13 +274,23 @@ def init_validate_provider() -> Any: data = request.get_json(silent=True) or {} key = data.get("key", "") - from solstone.think.providers import validate_key - try: + from solstone.convey.provider_readiness import chat_view + from solstone.think.providers import validate_key + result = validate_key("google", key) - except Exception as e: - result = {"valid": False, "error": str(e)} - return jsonify(result) + except Exception: + logger.exception("provider validation dispatch failed") + return error_response(PROVIDER_VALIDATION_FAILED) + + if result.get("valid"): + return jsonify({"valid": True}) + + reason_code = result.get("reason_code") or "unknown" + view = chat_view(reason_code, "google") + return jsonify( + {"valid": False, "message": view["message"], "reason_code": reason_code} + ) @bp.route("/init/observers") @@ -354,6 +365,7 @@ def init_finalize() -> Any: ) data = request.get_json(silent=True) or {} + warnings: list[str] = [] from solstone.think.utils import now_ms @@ -404,8 +416,18 @@ def init_finalize() -> Any: start_secure_listener(current_app._get_current_object()) except Exception: logger.error("secure listener start after finalize FAILED") + warnings.append( + "Setup is complete, but secure network access didn't start — " + "you can retry pairing from settings." + ) - return jsonify({"success": True, "redirect": url_for("app:thinking.index")}) + return jsonify( + { + "success": True, + "redirect": url_for("app:thinking.index"), + "warnings": warnings, + } + ) @bp.route("/favicon.ico") diff --git a/solstone/convey/templates/init.html b/solstone/convey/templates/init.html index 78b847cd2..f32e5bc4e 100644 --- a/solstone/convey/templates/init.html +++ b/solstone/convey/templates/init.html @@ -368,7 +368,7 @@ body: JSON.stringify({key}) }); if (result.valid) showFieldStatus(input, 'saved', 'connected'); - else showFieldStatus(input, 'error', result.error || 'validation failed'); + else showFieldStatus(input, 'error', result.message || 'validation failed'); if (input.value.trim() === validatedValue) { button.textContent = result.valid ? 'connected' : 'failed'; } @@ -659,6 +659,14 @@ }) }); if (data && data.success && data.redirect) { + if (data.warnings && data.warnings.length) { + showFinalizeError(errorEl, data.warnings.join(' ')); + if (button) button.textContent = 'redirecting…'; + setTimeout(() => { + window.location.href = data.redirect; + }, RESULT_DISPLAY_MS); + return; + } if (button) button.textContent = 'redirecting…'; window.location.href = data.redirect; return; diff --git a/solstone/observe/sense.py b/solstone/observe/sense.py index bb8de65d9..9eaabc167 100644 --- a/solstone/observe/sense.py +++ b/solstone/observe/sense.py @@ -629,7 +629,7 @@ class FileSensor: health_dir.mkdir(parents=True, exist_ok=True) (health_dir / "stream.updated").touch() except Exception: - pass + logger.debug("failed to touch stream.updated marker", exc_info=True) # Cleanup segment tracking del self.segment_files[segment] diff --git a/solstone/think/capture_health.py b/solstone/think/capture_health.py index 5d341473e..01d178693 100644 --- a/solstone/think/capture_health.py +++ b/solstone/think/capture_health.py @@ -12,8 +12,12 @@ propagating; callers render a neutral UI instead of crashing. from __future__ import annotations +import logging + from solstone.think.utils import now_ms +logger = logging.getLogger(__name__) + _CONNECTED_MS = 30_000 _STALE_MS = 120_000 CAPTURE_STATUS_DEGRADED = "degraded" @@ -107,4 +111,5 @@ def get_capture_health() -> dict: "observers": observer_summaries, } except Exception: + logger.debug("failed to derive capture health", exc_info=True) return {"status": "unknown", "observers": []} diff --git a/solstone/think/doctor.py b/solstone/think/doctor.py index 4ad95e343..059a0d555 100644 --- a/solstone/think/doctor.py +++ b/solstone/think/doctor.py @@ -44,7 +44,7 @@ from pathlib import Path from typing import IO, Callable, Sequence from solstone.think import features as _features -from solstone.think import parakeet_readiness, skills_cli +from solstone.think import maint, parakeet_readiness, skills_cli from solstone.think.health_cli import fetch_supervisor_status from solstone.think.probe import ( CONFIG_DIR_READABLE_CHECK, @@ -74,7 +74,7 @@ from solstone.think.service import ( ) from solstone.think.setup_events import STATUS_TRANSLATION, JsonlEmitter, utc_now_iso from solstone.think.sync_check import check_journal_sync, format_doctor_report -from solstone.think.utils import get_journal_info, is_packaged_install +from solstone.think.utils import get_journal_info, is_packaged_install, now_ms @dataclass(frozen=True) @@ -129,6 +129,7 @@ _COREML_MODEL_FIX = ( "CoreML parakeet model is not downloaded — fetch it with: journal install-models" ) JOURNAL_CAUGHT_UP_CHECK = Check("journal_caught_up", "advisory", ("linux", "darwin")) +JOURNAL_MAINT_TASKS_CHECK = Check("journal_maint_tasks", "blocker", ("linux", "darwin")) TASK_PACE_CHECK = Check("task_pace", "advisory", ("linux", "darwin")) OBSERVER_INGEST_HEALTH_CHECK = Check( "observer_ingest_health", "advisory", ("linux", "darwin") @@ -138,6 +139,10 @@ _CAUGHT_UP_BACKLOG_FIX = ( "to prioritize it" ) _CAUGHT_UP_CANT_TELL_FIX = "re-run journal doctor; check the health logs if it persists" +_MAINT_STALE_MS = 5 * 60 * 1000 +_MAINT_TASK_FIX = ( + "inspect with journal maint ; re-run with journal maint --force " +) _TASK_PACE_FIX = ( "a job is running long; it will be stopped automatically if it passes its cap " "— no action needed unless it persists" @@ -665,6 +670,52 @@ def journal_caught_up_check(args: Args) -> CheckResult: return make_result(check, "warn", detail, _CAUGHT_UP_BACKLOG_FIX) +def journal_maint_tasks_check(args: Args) -> CheckResult: + del args + check = JOURNAL_MAINT_TASKS_CHECK + path_text, _source = get_journal_info() + journal = Path(path_text) + if not journal.is_dir(): + return make_result(check, "skip", "no local journal") + + tasks = maint.list_tasks(journal) + failed = [task for task in tasks if task.get("status") == "failed"] + if failed: + detail = "failed maint task(s): " + ", ".join( + f"{task['qualified_name']} (exit {task.get('exit_code', 'unknown')})" + for task in failed + ) + return make_result(check, "fail", detail, _MAINT_TASK_FIX) + + current_ms = now_ms() + stale = [ + task + for task in tasks + if task.get("status") == "in_progress" + and isinstance(task.get("ran_ts"), int) + and current_ms - task["ran_ts"] > _MAINT_STALE_MS + ] + if stale: + detail = "started, no exit: " + ", ".join( + str(task["qualified_name"]) for task in stale + ) + return make_result(check, "warn", detail, _MAINT_TASK_FIX) + + indeterminate = [ + task + for task in tasks + if task.get("status") == "in_progress" + and not isinstance(task.get("ran_ts"), int) + ] + if indeterminate: + detail = "couldn't fully determine — maint state unreadable: " + ", ".join( + str(task["qualified_name"]) for task in indeterminate + ) + return make_result(check, "warn", detail, _MAINT_TASK_FIX) + + return make_result(check, "ok", "no unresolved maint tasks") + + def task_pace_check(args: Args) -> CheckResult: del args check = TASK_PACE_CHECK @@ -859,6 +910,7 @@ JOURNAL_CHECKS: list[tuple[Check, Runner]] = [ (SERVICE_RUNNING_CHECK, service_running_check), (JOURNAL_SYNC_CHECK, journal_sync_check), (JOURNAL_CAUGHT_UP_CHECK, journal_caught_up_check), + (JOURNAL_MAINT_TASKS_CHECK, journal_maint_tasks_check), (TASK_PACE_CHECK, task_pace_check), (OBSERVER_INGEST_HEALTH_CHECK, observer_ingest_health_check), (STALE_ALIAS_CHECK, partial(stale_alias_symlink_check, binary="journal")), diff --git a/solstone/think/models.py b/solstone/think/models.py index 70fa66de5..c7d385492 100644 --- a/solstone/think/models.py +++ b/solstone/think/models.py @@ -828,8 +828,7 @@ def log_token_usage( f.write(json.dumps(token_data) + "\n") except Exception: - # Silently fail - logging shouldn't break the main flow - pass + logger.warning("failed to log token usage", exc_info=True) _CLOUD_MODEL_FAMILIES = {"openai", "google", "anthropic"} diff --git a/solstone/think/pipeline_health.py b/solstone/think/pipeline_health.py index 6afa64501..f87e36041 100644 --- a/solstone/think/pipeline_health.py +++ b/solstone/think/pipeline_health.py @@ -269,6 +269,12 @@ def summarize_pipeline_day(day: str) -> dict: try: health_dir = day_path(day, create=False) / "health" if not health_dir.is_dir(): + today = _now().strftime("%Y%m%d") + if day < today and cluster_segments(day): + summary["status"] = "unknown" + summary["anomalies"].append( + {"kind": "segments_not_thought", "error": "no_health_dir"} + ) return summary for path in sorted(health_dir.glob("*.jsonl")): @@ -329,12 +335,18 @@ def summarize_pipeline_day(day: str) -> dict: except (TypeError, ValueError): duration_ms = 0 summary["runs"][mode]["duration_ms_total"] += duration_ms + except ValueError: + return summary except Exception: logger.warning( "pipeline_health: unexpected error summarizing %s", day, exc_info=True, ) + summary["status"] = "unknown" + summary["anomalies"].append( + {"kind": "segments_not_thought", "error": "scan_failed"} + ) return summary outstanding = [ @@ -1577,7 +1589,7 @@ def pipeline_status_message(summary: dict) -> dict | None: if seg is not None: if seg.get("error"): return { - "status": "stale", + "status": summary.get("status", "stale"), "message": "Segment thinking status unavailable", } count = seg.get("not_thought", 0) diff --git a/tests/test_capture_health.py b/tests/test_capture_health.py index 9acbb9fdb..70685a77a 100644 --- a/tests/test_capture_health.py +++ b/tests/test_capture_health.py @@ -35,15 +35,17 @@ def test_disabled_observers_excluded(monkeypatch): assert result["status"] == "no_observers" -def test_list_observers_raises_returns_unknown(monkeypatch): +def test_list_observers_raises_returns_unknown(monkeypatch, caplog): def _raise() -> list[dict]: raise RuntimeError("boom") monkeypatch.setattr("solstone.apps.observer.utils.list_observers", _raise) - result = get_capture_health() + with caplog.at_level("DEBUG", logger="solstone.think.capture_health"): + result = get_capture_health() assert result == {"status": "unknown", "observers": []} + assert "failed to derive capture health" in caplog.text def test_degraded_status_from_rejection(monkeypatch): diff --git a/tests/test_convey_apps.py b/tests/test_convey_apps.py index f91fa5a8d..cb76d0568 100644 --- a/tests/test_convey_apps.py +++ b/tests/test_convey_apps.py @@ -297,6 +297,31 @@ class TestInjectedChatBarContext: assert context["chat_bar_sol_request"] is None + def test_awareness_context_failure_logs_and_uses_fallback( + self, monkeypatch, tmp_path, caplog + ): + import solstone.convey.state as convey_state + + def fail_current(): + raise RuntimeError("boom") + + app = Flask(__name__) + registry = AppRegistry() + monkeypatch.setattr(convey_state, "journal_root", str(tmp_path)) + monkeypatch.setattr("solstone.convey.apps._get_facets_data", lambda: []) + monkeypatch.setattr("solstone.convey.apps._get_selected_facet", lambda: None) + monkeypatch.setattr("solstone.think.awareness.get_current", fail_current) + register_app_context(app, registry) + + with caplog.at_level("WARNING", logger="solstone.convey.apps"): + with app.test_request_context("/"): + context: dict = {} + app.update_template_context(context) + + assert context["chat_bar_placeholder"] == "Send a message..." + assert context["chat_bar_attention"] is None + assert "failed to resolve chat bar awareness context" in caplog.text + class TestAttentionResolution: """Tests for _resolve_attention() and attention-aware placeholder resolution.""" @@ -312,6 +337,21 @@ class TestAttentionResolution: current = {"imports": {"has_imported": True}, "journal": {}} assert _resolve_attention(current) is None + def test_cortex_attention_failure_logs_and_falls_through(self, monkeypatch, caplog): + from solstone.convey.apps import _resolve_attention + + def fail_scan(): + raise RuntimeError("boom") + + monkeypatch.setattr( + "solstone.convey.apps.read_unresolved_agent_failures", fail_scan + ) + + with caplog.at_level("WARNING", logger="solstone.convey.apps"): + assert _resolve_attention({}) is None + + assert "failed to resolve chat bar cortex attention" in caplog.text + def test_p1_recent_import(self): from datetime import datetime diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 5502f0fbc..f90fbba49 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -555,6 +555,20 @@ class TestHostDependencies: } assert doctor.CHECK_MAP["host_dependencies"].severity == "blocker" + def test_maint_tasks_registered_in_journal_and_check_map(self, doctor): + pair = ( + doctor.JOURNAL_MAINT_TASKS_CHECK, + doctor.journal_maint_tasks_check, + ) + + assert doctor.JOURNAL_MAINT_TASKS_CHECK.name == "journal_maint_tasks" + assert doctor.JOURNAL_MAINT_TASKS_CHECK.name in doctor.CHECK_MAP + assert doctor.CHECK_MAP["journal_maint_tasks"].severity == "blocker" + assert pair in doctor.JOURNAL_CHECKS + assert pair not in doctor.UNIVERSAL_CHECKS + assert pair not in doctor.READINESS_CHECKS + assert pair not in doctor.JOURNAL_READINESS_CHECKS + def test_host_dependencies_ok_when_present(self, doctor): result = doctor.host_dependencies_check(args(doctor)) diff --git a/tests/test_init.py b/tests/test_init.py index adece4b72..19c538e62 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -408,6 +408,7 @@ class TestInitMark: def test_finalize_catch_routes_server_error(self, fresh_client): resp = fresh_client.get("/init") + assert b"data.warnings" in resp.data assert b"err.serverMessage" in resp.data assert b"showFinalizeError" in resp.data @@ -452,6 +453,8 @@ class TestInitValidateProvider: assert data["valid"] is True def test_validate_provider_invalid_key(self, fresh_client, monkeypatch): + from solstone.convey.provider_readiness import chat_view + monkeypatch.setattr( "solstone.think.providers.validate_key", lambda provider, key: {"valid": False, "error": "Invalid key"}, @@ -464,7 +467,54 @@ class TestInitValidateProvider: assert resp.status_code == 200 data = resp.get_json() assert data["valid"] is False - assert data["error"] == "Invalid key" + assert data["reason_code"] == "unknown" + assert data["message"] == chat_view("unknown", "google")["message"] + assert data["message"] != "Invalid key" + + def test_validate_provider_provider_failure_no_leak( + self, fresh_client, monkeypatch + ): + from solstone.think.providers import google + + def fail_probe(_api_key): + raise ConnectionError("SECRET-TRACEBACK-XYZ") + + monkeypatch.setattr(google, "_probe_backend", fail_probe) + + resp = fresh_client.post( + "/init/validate-provider", + json={"key": "bad-key"}, + content_type="application/json", + ) + + text = resp.get_data(as_text=True) + data = resp.get_json() + assert resp.status_code == 200 + assert data["valid"] is False + assert data["reason_code"] == "network_unreachable" + assert "SECRET-TRACEBACK-XYZ" not in text + assert "Traceback" not in text + assert "RuntimeError" not in text + + def test_validate_provider_dispatch_failure_non_2xx( + self, fresh_client, monkeypatch + ): + def fail_validate(_provider, _key): + raise RuntimeError("SECRET-DISPATCH") + + monkeypatch.setattr("solstone.think.providers.validate_key", fail_validate) + + resp = fresh_client.post( + "/init/validate-provider", + json={"key": "bad-key"}, + content_type="application/json", + ) + + text = resp.get_data(as_text=True) + data = resp.get_json() + assert resp.status_code == 500 + assert data["reason_code"] == "provider_validation_failed" + assert "SECRET-DISPATCH" not in text def test_validate_provider_no_config_write( self, fresh_client, journal_copy, monkeypatch @@ -655,6 +705,45 @@ class TestInitFinalize: assert "completed_at" in config["setup"] assert "allow_network_access" not in config["convey"] + def test_finalize_warns_when_secure_listener_fails(self, fresh_client, monkeypatch): + _commit_journal_identity() + + def fail_start(_app): + raise RuntimeError("listener boom") + + monkeypatch.setattr("solstone.convey.root.start_secure_listener", fail_start) + + resp = fresh_client.post( + "/init/finalize", + json={"name": "Jane"}, + content_type="application/json", + ) + + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert data["redirect"] == "/app/thinking/" + assert isinstance(data["warnings"], list) + assert data["warnings"] + + def test_finalize_happy_path_returns_empty_warnings( + self, fresh_client, monkeypatch + ): + _commit_journal_identity() + monkeypatch.setattr( + "solstone.convey.root.start_secure_listener", + lambda _app: None, + ) + + resp = fresh_client.post( + "/init/finalize", + json={"name": "Jane"}, + content_type="application/json", + ) + + assert resp.status_code == 200 + assert resp.get_json()["warnings"] == [] + def test_finalize_minimal(self, fresh_client, journal_copy): """Finalize with optional fields omitted.""" _commit_journal_identity() diff --git a/tests/test_journal_doctor.py b/tests/test_journal_doctor.py index c202dc6ba..79480d241 100644 --- a/tests/test_journal_doctor.py +++ b/tests/test_journal_doctor.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import os import plistlib from pathlib import Path @@ -32,6 +33,24 @@ def args(doctor): return doctor.Args(verbose=False, json=False, jsonl=False, port=5015) +MAINT_APP = "sol" +MAINT_TASK = "000_migrate_agent_layout" +MAINT_QUALIFIED = f"{MAINT_APP}:{MAINT_TASK}" + + +def maint_state_file(journal: Path) -> Path: + path = journal / "maint" / MAINT_APP / f"{MAINT_TASK}.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def write_maint_events(journal: Path, events: list[dict]) -> None: + maint_state_file(journal).write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + + def make_repo(tmp_path: Path, *, worktree: bool = False) -> Path: repo = tmp_path / "repo" repo.mkdir() @@ -164,6 +183,77 @@ def test_observer_ingest_health_ok_and_skip(doctor, monkeypatch): assert result.status == "skip" +def test_journal_maint_tasks_failed_state_fails(doctor, monkeypatch, tmp_path): + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(tmp_path), "source")) + write_maint_events( + tmp_path, + [ + {"event": "exec", "ts": 1000}, + {"event": "exit", "ts": 2000, "exit_code": 2}, + ], + ) + + result = doctor.journal_maint_tasks_check(args(doctor)) + + assert result.status == "fail" + assert MAINT_QUALIFIED in result.detail + assert "exit 2" in result.detail + assert result.fix is not None + assert "journal maint " in result.fix + assert "journal maint --force " in result.fix + + +def test_journal_maint_tasks_stale_in_progress_warns(doctor, monkeypatch, tmp_path): + now = 10_000_000 + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(tmp_path), "source")) + monkeypatch.setattr(doctor, "now_ms", lambda: now) + write_maint_events( + tmp_path, + [{"event": "exec", "ts": now - doctor._MAINT_STALE_MS - 1}], + ) + + result = doctor.journal_maint_tasks_check(args(doctor)) + + assert result.status == "warn" + assert "started, no exit" in result.detail + assert MAINT_QUALIFIED in result.detail + assert result.fix is not None + assert "journal maint --force " in result.fix + + +def test_journal_maint_tasks_pending_is_ok(doctor, monkeypatch, tmp_path): + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(tmp_path), "source")) + + result = doctor.journal_maint_tasks_check(args(doctor)) + + assert result.status == "ok" + assert result.detail == "no unresolved maint tasks" + + +def test_journal_maint_tasks_exec_less_state_warns(doctor, monkeypatch, tmp_path): + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(tmp_path), "source")) + write_maint_events( + tmp_path, + [{"event": "line", "ts": 1000, "line": "started but no exec"}], + ) + + result = doctor.journal_maint_tasks_check(args(doctor)) + + assert result.status == "warn" + assert result.detail.startswith("couldn't fully determine") + assert MAINT_QUALIFIED in result.detail + + +def test_journal_maint_tasks_no_local_journal_skips(doctor, monkeypatch, tmp_path): + missing = tmp_path / "missing-journal" + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(missing), "source")) + + result = doctor.journal_maint_tasks_check(args(doctor)) + + assert result.status == "skip" + assert result.detail == "no local journal" + + def test_service_running_crash_loop_fails(doctor, monkeypatch): monkeypatch.setattr(doctor, "service_is_installed", lambda: True) monkeypatch.setattr( diff --git a/tests/test_models.py b/tests/test_models.py index 2cb3089a7..edc23f6f6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1317,6 +1317,31 @@ def test_log_token_usage_passes_through_cache_creation_tokens(tmp_path, monkeypa assert entry["usage"]["cached_tokens"] == 3000 +def test_log_token_usage_logs_append_failure(tmp_path, monkeypatch, caplog): + import builtins + + from solstone.think.models import log_token_usage + + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + real_open = builtins.open + + def fail_token_open(path, *args, **kwargs): + if str(path).endswith(".jsonl"): + raise OSError("disk full") + return real_open(path, *args, **kwargs) + + monkeypatch.setattr(builtins, "open", fail_token_open) + + with caplog.at_level(logging.WARNING, logger="solstone.think.models"): + log_token_usage( + model="gpt-5.2", + usage={"input_tokens": 1000, "output_tokens": 200}, + context="test", + ) + + assert "failed to log token usage" in caplog.text + + class TestModelSupports: def test_opus_4_7_temperature_not_supported(self): assert model_supports(CLAUDE_OPUS_4, "temperature") is False diff --git a/tests/test_pipeline_health.py b/tests/test_pipeline_health.py index 17a50af8e..117b57e5f 100644 --- a/tests/test_pipeline_health.py +++ b/tests/test_pipeline_health.py @@ -1115,6 +1115,18 @@ def test_missing_health_dir(pipeline_journal): assert summary["runs"]["daily"]["count"] == 0 +def test_missing_health_dir_with_captured_segments_is_unknown(pipeline_journal): + day = "20200101" + _seed_screen_segment(pipeline_journal, day, "120000_300") + + summary = summarize_pipeline_day(day) + + assert summary["status"] == "unknown" + assert {"kind": "segments_not_thought", "error": "no_health_dir"} in summary[ + "anomalies" + ] + + def test_healthy_day_with_all_modes(pipeline_journal): day = "20990101" base = pipeline_journal / "chronicle" / day / "health" @@ -1504,6 +1516,22 @@ def test_segment_completion_fold_failure_elevates_status( } +def test_scan_failure_elevates_status_to_unknown(pipeline_journal, monkeypatch): + from solstone.think import pipeline_health + + def fail_day_path(*_args, **_kwargs): + raise OSError("boom") + + monkeypatch.setattr(pipeline_health, "day_path", fail_day_path) + + summary = summarize_pipeline_day("20200102") + + assert summary["status"] == "unknown" + assert {"kind": "segments_not_thought", "error": "scan_failed"} in summary[ + "anomalies" + ] + + def test_invalid_day_returns_healthy_empty(pipeline_journal): summary = summarize_pipeline_day("not-a-date") @@ -2229,6 +2257,20 @@ def test_historical_failures_with_latest_complete_are_not_pending(pipeline_journ "message": "Segment thinking status unavailable", }, ), + ( + { + "status": "unknown", + "anomalies": [ + {"kind": "segments_not_thought", "error": "no_health_dir"} + ], + "talents": {"failed": 0}, + "day": "20260101", + }, + { + "status": "unknown", + "message": "Segment thinking status unavailable", + }, + ), ], ) def test_status_message_priorities(summary, expected): diff --git a/tests/test_sense.py b/tests/test_sense.py index 79057c698..65e9bddb3 100644 --- a/tests/test_sense.py +++ b/tests/test_sense.py @@ -1519,6 +1519,23 @@ def test_file_sensor_live_observed_touches_stream_updated( assert marker.exists() +def test_file_sensor_live_observed_logs_stream_updated_failure( + tmp_path, monkeypatch, mock_callosum, caplog +): + def fail_day_path(_day): + raise OSError("boom") + + monkeypatch.setattr("solstone.observe.sense.day_path", fail_day_path) + + with caplog.at_level("DEBUG", logger="solstone.observe.sense"): + observed = _observed_event_from_observing( + tmp_path, monkeypatch, mock_callosum, batch=False + ) + + assert observed["segment"] == "143022_300" + assert "failed to touch stream.updated marker" in caplog.text + + def test_file_sensor_batch_observed_does_not_touch_stream_updated( tmp_path, monkeypatch, mock_callosum ): diff --git a/tests/test_steward.py b/tests/test_steward.py index e2826a28e..49556bc01 100644 --- a/tests/test_steward.py +++ b/tests/test_steward.py @@ -563,6 +563,24 @@ def test_render_health_body_activity_gap_bullet(): assert "3 activities ended yesterday" in body +def test_render_health_body_segment_status_unknown_bullet(): + pipeline_day = { + "status": "unknown", + "anomalies": [{"kind": "segments_not_thought", "error": "no_health_dir"}], + } + + body = render_health_body( + generated_at=_GEN_AT, + pipeline_day=pipeline_day, + recipe_outcomes_7d=[], + data_source_errors=[], + ) + + assert validate_steward_health(body) is None + assert "sol is well." not in body + assert "Segment thinking status could not be determined." in body + + def test_render_health_body_talent_failure_timed_out(): pipeline_day = { "anomalies": [