From 70cb9564fb44eb496db8fa12dd1a0be56380f6b4 Mon Sep 17 00:00:00 2001 From: Clayde Date: Wed, 27 May 2026 09:49:38 +0000 Subject: [PATCH] Fix #85: alert via ntfy when Claude auth fails Distinguish Claude authentication failures from usage/rate limits in the availability pre-check so an expired/replaced credentials situation no longer silently skips work every cycle. - claude.py: add ClaudeStatus enum; rename backend is_available() -> check_availability() returning AVAILABLE / USAGE_LIMIT / AUTH_FAILED; add module-level check_claude_availability() (is_claude_available() kept as a bool wrapper). - orchestrator.py: on AUTH_FAILED send a one-shot high-priority ntfy alert and skip; on USAGE_LIMIT keep existing silent skip. The latch is cleared once Claude is reachable again so a future failure re-alerts. - state.py: persist the notified latch in a top-level claude_auth_failure_notified flag (get/set helpers) so the alert fires once per failure streak rather than every cycle. - Update README + CLAUDE.md; add tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 11 +++++++ README.md | 12 +++++++- src/clayde/claude.py | 61 ++++++++++++++++++++++++++++---------- src/clayde/orchestrator.py | 58 ++++++++++++++++++++++++++++++++++-- src/clayde/state.py | 17 +++++++++++ tests/test_claude.py | 58 ++++++++++++++++++++++++++++-------- tests/test_orchestrator.py | 59 +++++++++++++++++++++++++++++++----- tests/test_state.py | 31 +++++++++++++++++++ 8 files changed, 267 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dedf3df..1cbaa38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,17 @@ leave `in_progress=True` so the next cycle retries automatically. Other exceptions clear `in_progress` and log the error. Closed issues are pruned from state at the start of each tick. +**Availability pre-check**: each tick begins with +`check_claude_availability()` (`claude.py`), which returns a `ClaudeStatus` +of `AVAILABLE`, `USAGE_LIMIT`, or `AUTH_FAILED`. A usage limit skips the cycle +silently (transient — retried next tick). An auth failure (expired/replaced +credentials) sends a one-shot high-priority ntfy alert ("Clayde: Claude CLI +auth failed") so the operator can re-authenticate and restart. The alert +fires once per failure streak — tracked by the top-level +`claude_auth_failure_notified` flag in `state.json` +(`get_claude_auth_notified()` / `set_claude_auth_notified()`) — and re-arms +when Claude becomes reachable again. + --- ## Safety & Content Filtering diff --git a/README.md b/README.md index 34b34e5..c9ffadf 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,15 @@ Clayde's loop is event-driven and stateless by design: 7. **Pure PR approvals** (no comments) update `last_seen_at` without invoking Claude. 8. **Closed issues** are pruned from state automatically. +Before any issues are processed, each tick runs a Claude availability +pre-check. A **usage/rate limit** simply skips the cycle (it is transient and +retried next tick). An **authentication failure** — credentials expired or +replaced — instead sends a one-shot high-priority ntfy alert (title *"Clayde: +Claude CLI auth failed"*) so the operator can re-authenticate and restart the +container. The alert fires once per failure streak (tracked by +`claude_auth_failure_notified` in `state.json`) and re-arms once Claude is +reachable again. + --- ## Safety & Content Filtering @@ -60,6 +69,7 @@ Whitelisted users are configured via `CLAYDE_WHITELISTED_USERS` in `data/config. - **PR creation by Claude**: Claude writes the PR description and a recommended reading order for larger diffs - **PR review handling**: Reads and addresses reviewer feedback automatically - **Rate-limit resilience**: Detects Claude usage limits and automatically retries +- **Auth-failure alerting**: Distinguishes Claude authentication failures from usage limits and sends a one-shot high-priority ntfy alert so the operator can re-authenticate - **Crash recovery**: `in_progress` flag ensures interrupted runs are retried next cycle - **Safety filtering**: Whitelist-based content filtering prevents acting on unauthorized content - **Observability**: OpenTelemetry tracing with JSONL file export @@ -179,7 +189,7 @@ In any repository the bot has access to, assign issues to the bot account. Clayd | `CLAYDE_PEBBLE_PORT` | Internal HTTP port (default `8080`) | | `CLAYDE_PEBBLE_TIMEOUT` | Per-request CLI timeout seconds (default `300`) | | `CLAYDE_PEBBLE_QUEUE_MAX` | Max queued jobs before 503 (default `100`) | -| `CLAYDE_NTFY_TOPIC` | ntfy.sh topic for Pebble outcome notifications | +| `CLAYDE_NTFY_TOPIC` | ntfy.sh topic for Pebble outcome notifications and Claude auth-failure alerts | | `CLAYDE_NTFY_BASE_URL` | ntfy base URL (override for self-host) | | `CLAYDE_NTFY_TIMEOUT_S` | ntfy POST timeout seconds (default `10`) | | `CLAYDE_KB_PATH` | In-container KB path; Pebble per-request cwd (default `/home/clayde/knowledge_base`) | diff --git a/src/clayde/claude.py b/src/clayde/claude.py index 48b504f..a176bfa 100644 --- a/src/clayde/claude.py +++ b/src/clayde/claude.py @@ -1,6 +1,7 @@ """Claude invocation via the Anthropic API or the Claude Code CLI.""" import dataclasses +import enum import json import logging import os @@ -52,6 +53,20 @@ ] +class ClaudeStatus(enum.Enum): + """Outcome of a Claude availability pre-check. + + Distinguishes a recoverable usage/rate limit (transient — just skip the + cycle) from an authentication failure (requires operator intervention — + re-authenticate and restart). ``AVAILABLE`` covers both success and any + unrecognised error (fail-open). + """ + + AVAILABLE = "available" + USAGE_LIMIT = "usage_limit" + AUTH_FAILED = "auth_failed" + + @dataclasses.dataclass class InvocationResult: """Result of a Claude invocation, including output text and cost.""" @@ -130,7 +145,7 @@ def invoke( ) -> InvocationResult: ... @abstractmethod - def is_available(self) -> bool: ... + def check_availability(self) -> ClaudeStatus: ... # --------------------------------------------------------------------------- @@ -428,7 +443,7 @@ def invoke( input_tokens=total_input, output_tokens=total_output, ) - def is_available(self) -> bool: + def check_availability(self) -> ClaudeStatus: tracer = get_tracer() with tracer.start_as_current_span("clayde.claude_available_check") as span: try: @@ -439,21 +454,22 @@ def is_available(self) -> bool: messages=[{"role": "user", "content": "respond with: OK"}], ) span.set_attribute("claude.available", True) - return True + return ClaudeStatus.AVAILABLE except anthropic.RateLimitError as e: log.warning("Claude availability check: rate limit hit — %s", e) span.set_attribute("claude.available", False) - return False + return ClaudeStatus.USAGE_LIMIT except anthropic.AuthenticationError as exc: log.error("Claude availability check: authentication failed — %s", exc) span.set_attribute("claude.available", False) + span.set_attribute("claude.auth_failed", True) span.set_attribute("claude.check_error", str(exc)) - return False + return ClaudeStatus.AUTH_FAILED except Exception as exc: log.warning("Claude availability pre-check raised %s — assuming available", exc) span.set_attribute("claude.available", True) span.set_attribute("claude.check_error", str(exc)) - return True + return ClaudeStatus.AVAILABLE # --------------------------------------------------------------------------- @@ -659,7 +675,7 @@ def invoke( input_tokens=0, output_tokens=0, ) - def is_available(self) -> bool: + def check_availability(self) -> ClaudeStatus: tracer = get_tracer() with tracer.start_as_current_span("clayde.claude_available_check") as span: cli_bin = _resolve_cli_bin() @@ -683,18 +699,19 @@ def is_available(self) -> bool: error_text += " " + (result.stdout or "") if _is_limit_error(error_text): span.set_attribute("claude.available", False) - return False + return ClaudeStatus.USAGE_LIMIT if is_error and _is_auth_error(error_text): log.warning("Claude CLI authentication failed — marking unavailable") span.set_attribute("claude.available", False) - return False + span.set_attribute("claude.auth_failed", True) + return ClaudeStatus.AUTH_FAILED span.set_attribute("claude.available", True) - return True + return ClaudeStatus.AVAILABLE except Exception as exc: log.warning("Claude CLI availability pre-check raised %s — assuming available", exc) span.set_attribute("claude.available", True) span.set_attribute("claude.check_error", str(exc)) - return True + return ClaudeStatus.AVAILABLE # --------------------------------------------------------------------------- @@ -750,11 +767,23 @@ def invoke_claude( ) +def check_claude_availability() -> ClaudeStatus: + """Return the current Claude availability status. + + Makes a minimal invocation and distinguishes the failure modes so callers + can react differently: a usage/rate limit is transient (skip the cycle and + retry), whereas an authentication failure needs operator intervention + (re-authenticate and restart). Returns ``ClaudeStatus.AVAILABLE`` on + success or any unrecognised error (fail-open to avoid suppressing real + work on spurious pre-check errors). + """ + return _get_backend().check_availability() + + def is_claude_available() -> bool: - """Return True if Claude is available (rate limit not currently hit). + """Return True if Claude is available (no usage limit or auth failure). - Makes a minimal invocation. Returns False when a limit is detected; - returns True on success or any other error (fail-open to avoid - suppressing real work on spurious pre-check errors). + Thin boolean wrapper over :func:`check_claude_availability` for callers + that don't need to distinguish the failure modes. """ - return _get_backend().is_available() + return check_claude_availability() is ClaudeStatus.AVAILABLE diff --git a/src/clayde/orchestrator.py b/src/clayde/orchestrator.py index 74ef059..e8ea940 100644 --- a/src/clayde/orchestrator.py +++ b/src/clayde/orchestrator.py @@ -27,9 +27,15 @@ from github import Github from github.Issue import Issue -from clayde.claude import InvocationTimeoutError, UsageLimitError, is_claude_available +from clayde.claude import ( + ClaudeStatus, + InvocationTimeoutError, + UsageLimitError, + check_claude_availability, +) from clayde.config import get_github_client, get_settings, setup_logging from clayde.webhook import JobQueue, create_app, worker_loop +from clayde.webhook.notify import send_ntfy_sync from clayde.github import ( fetch_issue, fetch_issue_comments, @@ -44,7 +50,14 @@ parse_pr_url, ) from clayde.safety import filter_pr_reviews, get_new_visible_comments, has_visible_content -from clayde.state import get_issue_state, load_state, save_state, update_issue_state +from clayde.state import ( + get_claude_auth_notified, + get_issue_state, + load_state, + save_state, + set_claude_auth_notified, + update_issue_state, +) from clayde.tasks import work, wrap_up, pr_work from clayde.telemetry import get_tracer, init_tracer @@ -306,6 +319,32 @@ def _prune_closed_issues(g: Github, issues_state: dict) -> None: save_state(state) +def _notify_claude_auth_failure(settings) -> None: + """Send a one-shot high-priority ntfy alert when Claude auth fails. + + Fires once per auth-failure streak: the notified flag is set here and + cleared in ``main()`` once Claude becomes available again. A persistent + failure therefore alerts only on the first cycle, while a + recovered-then-failed sequence alerts again. + """ + if get_claude_auth_notified(): + log.warning("Claude authentication still failing — already alerted, skipping ntfy") + return + log.error("Claude authentication failed — sending operator alert") + send_ntfy_sync( + title="Clayde: Claude CLI auth failed", + body=( + "Claude authentication failed — Clayde is skipping all work. " + "Re-authenticate (claude auth login) and restart the container." + ), + success=False, + base_url=settings.ntfy_base_url, + topic=settings.ntfy_topic, + timeout_s=settings.ntfy_timeout_s, + ) + set_claude_auth_notified(True) + + def _configure_global_git_identity(settings) -> None: git_name = settings.effective_git_name git_email = settings.git_email @@ -332,12 +371,25 @@ def main(): tracer = get_tracer() with tracer.start_as_current_span("clayde.tick") as tick_span: - if not is_claude_available(): + status = check_claude_availability() + if status is ClaudeStatus.AUTH_FAILED: + tick_span.set_attribute("claude.available", False) + tick_span.set_attribute("claude.auth_failed", True) + _notify_claude_auth_failure(settings) + provider.force_flush() + return + if status is ClaudeStatus.USAGE_LIMIT: log.warning("Claude usage limit hit — skipping all work this cycle") tick_span.set_attribute("claude.available", False) provider.force_flush() return + # Claude is available — clear any prior auth-failure alert latch so a + # future failure alerts again. + if get_claude_auth_notified(): + log.info("Claude authentication recovered — clearing auth-failure alert latch") + set_claude_auth_notified(False) + tick_span.set_attribute("claude.available", True) g = get_github_client() assigned = get_assigned_issues(g) diff --git a/src/clayde/state.py b/src/clayde/state.py index 81a06aa..68076e7 100644 --- a/src/clayde/state.py +++ b/src/clayde/state.py @@ -9,6 +9,11 @@ _STATE_FILE = DATA_DIR / "state.json" +# Top-level (non-issue) state key tracking whether the operator has been +# alerted about the current Claude auth-failure streak, so the alert fires +# once per streak rather than every cycle. +_CLAUDE_AUTH_NOTIFIED_KEY = "claude_auth_failure_notified" + def load_state() -> dict: if _STATE_FILE.exists(): @@ -29,3 +34,15 @@ def update_issue_state(issue_url: str, updates: dict) -> None: entry = state["issues"].setdefault(issue_url, {}) entry.update(updates) save_state(state) + + +def get_claude_auth_notified() -> bool: + """Return whether an alert has been sent for the current auth-failure streak.""" + return bool(load_state().get(_CLAUDE_AUTH_NOTIFIED_KEY, False)) + + +def set_claude_auth_notified(value: bool) -> None: + """Record whether the current Claude auth-failure streak has been notified.""" + state = load_state() + state[_CLAUDE_AUTH_NOTIFIED_KEY] = value + save_state(state) diff --git a/tests/test_claude.py b/tests/test_claude.py index 7d1066c..a016053 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -9,6 +9,7 @@ from clayde.claude import ( ApiBackend, + ClaudeStatus, CliBackend, InvocationResult, InvocationTimeoutError, @@ -17,6 +18,7 @@ _get_backend, _is_limit_error, _make_cli_env, + check_claude_availability, format_cost_line, invoke_claude, is_claude_available, @@ -397,9 +399,9 @@ def test_available_on_success(self): with patch.object(backend, "_get_client", return_value=mock_client), \ patch("clayde.claude.get_settings", return_value=_mock_settings()): - assert backend.is_available() is True + assert backend.check_availability() is ClaudeStatus.AVAILABLE - def test_unavailable_on_rate_limit(self): + def test_usage_limit_on_rate_limit(self): mock_client = MagicMock() mock_client.messages.create.side_effect = anthropic.RateLimitError( message="rate limit", response=MagicMock(), body={} @@ -408,7 +410,20 @@ def test_unavailable_on_rate_limit(self): with patch.object(backend, "_get_client", return_value=mock_client), \ patch("clayde.claude.get_settings", return_value=_mock_settings()): - assert backend.is_available() is False + assert backend.check_availability() is ClaudeStatus.USAGE_LIMIT + + def test_auth_failed_on_authentication_error(self): + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 401 + mock_client.messages.create.side_effect = anthropic.AuthenticationError( + message="invalid api key", response=mock_response, body={} + ) + backend = ApiBackend() + + with patch.object(backend, "_get_client", return_value=mock_client), \ + patch("clayde.claude.get_settings", return_value=_mock_settings()): + assert backend.check_availability() is ClaudeStatus.AUTH_FAILED def test_available_on_other_exception(self): mock_client = MagicMock() @@ -417,7 +432,7 @@ def test_available_on_other_exception(self): with patch.object(backend, "_get_client", return_value=mock_client), \ patch("clayde.claude.get_settings", return_value=_mock_settings()): - assert backend.is_available() is True + assert backend.check_availability() is ClaudeStatus.AVAILABLE def test_available_on_api_error_non_rate_limit(self): mock_client = MagicMock() @@ -430,7 +445,7 @@ def test_available_on_api_error_non_rate_limit(self): with patch.object(backend, "_get_client", return_value=mock_client), \ patch("clayde.claude.get_settings", return_value=_mock_settings()): - assert backend.is_available() is True + assert backend.check_availability() is ClaudeStatus.AVAILABLE class TestCommitWip: @@ -936,9 +951,9 @@ def test_available_on_success(self): with patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \ patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \ patch("clayde.claude.subprocess.run", return_value=mock_result): - assert backend.is_available() is True + assert backend.check_availability() is ClaudeStatus.AVAILABLE - def test_unavailable_on_limit(self): + def test_usage_limit_on_limit(self): mock_result = MagicMock() mock_result.stdout = "{}" mock_result.stderr = "You've reached your usage limit" @@ -948,9 +963,9 @@ def test_unavailable_on_limit(self): with patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \ patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \ patch("clayde.claude.subprocess.run", return_value=mock_result): - assert backend.is_available() is False + assert backend.check_availability() is ClaudeStatus.USAGE_LIMIT - def test_unavailable_on_not_logged_in(self): + def test_auth_failed_on_not_logged_in(self): mock_result = MagicMock() mock_result.stdout = '{"is_error": true, "result": "Not logged in \\u00b7 Please run /login"}' mock_result.stderr = "" @@ -960,9 +975,9 @@ def test_unavailable_on_not_logged_in(self): with patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \ patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \ patch("clayde.claude.subprocess.run", return_value=mock_result): - assert backend.is_available() is False + assert backend.check_availability() is ClaudeStatus.AUTH_FAILED - def test_unavailable_on_failed_to_authenticate(self): + def test_auth_failed_on_failed_to_authenticate(self): mock_result = MagicMock() mock_result.stdout = json.dumps({ "is_error": True, @@ -979,7 +994,7 @@ def test_unavailable_on_failed_to_authenticate(self): with patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \ patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \ patch("clayde.claude.subprocess.run", return_value=mock_result): - assert backend.is_available() is False + assert backend.check_availability() is ClaudeStatus.AUTH_FAILED def test_available_on_exception(self): backend = CliBackend() @@ -987,7 +1002,7 @@ def test_available_on_exception(self): with patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \ patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \ patch("clayde.claude.subprocess.run", side_effect=FileNotFoundError("not found")): - assert backend.is_available() is True + assert backend.check_availability() is ClaudeStatus.AVAILABLE # --------------------------------------------------------------------------- @@ -1026,3 +1041,20 @@ def test_is_claude_available_dispatches(self): with patch("clayde.claude.get_settings", return_value=_mock_settings(backend="api")), \ patch.object(ApiBackend, "_get_client", return_value=mock_client): assert is_claude_available() is True + + def test_check_claude_availability_dispatches(self): + mock_client = MagicMock() + mock_client.messages.create.return_value = MagicMock() + + with patch("clayde.claude.get_settings", return_value=_mock_settings(backend="api")), \ + patch.object(ApiBackend, "_get_client", return_value=mock_client): + assert check_claude_availability() is ClaudeStatus.AVAILABLE + + def test_is_claude_available_false_on_auth_failure(self): + with patch( + "clayde.claude._get_backend", + return_value=MagicMock( + check_availability=MagicMock(return_value=ClaudeStatus.AUTH_FAILED) + ), + ): + assert is_claude_available() is False diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index a29057c..470f8ca 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -6,6 +6,7 @@ import pytest +from clayde.claude import ClaudeStatus from clayde.orchestrator import ( _handle_issue, _handle_standalone_pr, @@ -28,7 +29,7 @@ def _mock_settings(enabled=False, github_token="tok", github_username="ClaydeCod @contextmanager -def _patched_main(enabled=True, claude_available=True, assigned=(), state=None): +def _patched_main(enabled=True, claude_status=ClaudeStatus.AVAILABLE, assigned=(), state=None): """Patch every external dependency `main()` touches. Yields a dict of name → mock so individual tests can assert on call behavior. @@ -38,7 +39,10 @@ def _patched_main(enabled=True, claude_available=True, assigned=(), state=None): "setup_logging": {}, "init_tracer": {}, "_configure_global_git_identity": {}, - "is_claude_available": {"return_value": claude_available}, + "check_claude_availability": {"return_value": claude_status}, + "get_claude_auth_notified": {"return_value": False}, + "set_claude_auth_notified": {}, + "send_ntfy_sync": {}, "get_github_client": {}, "get_assigned_issues": {"return_value": list(assigned)}, "load_state": {"return_value": state if state is not None else {"issues": {}}}, @@ -59,10 +63,11 @@ def test_exits_when_disabled(self): main() assert exc_info.value.code == 0 - def test_returns_when_claude_unavailable(self): - with _patched_main(claude_available=False) as mocks: + def test_returns_when_claude_usage_limited(self): + with _patched_main(claude_status=ClaudeStatus.USAGE_LIMIT) as mocks: main() mocks["get_github_client"].assert_not_called() + mocks["send_ntfy_sync"].assert_not_called() def test_returns_when_no_assigned_issues(self): with _patched_main(assigned=[]): @@ -83,6 +88,43 @@ def test_main_calls_prune(self): mocks["_prune_closed_issues"].assert_called_once() +class TestClaudeAuthFailureNotification: + def test_notifies_on_auth_failure(self): + with _patched_main(claude_status=ClaudeStatus.AUTH_FAILED) as mocks: + main() + mocks["send_ntfy_sync"].assert_called_once() + kwargs = mocks["send_ntfy_sync"].call_args.kwargs + assert kwargs["title"] == "Clayde: Claude CLI auth failed" + assert kwargs["success"] is False + mocks["set_claude_auth_notified"].assert_called_once_with(True) + # No work is attempted on auth failure + mocks["get_github_client"].assert_not_called() + + def test_does_not_renotify_when_already_alerted(self): + with _patched_main(claude_status=ClaudeStatus.AUTH_FAILED) as mocks: + mocks["get_claude_auth_notified"].return_value = True + main() + mocks["send_ntfy_sync"].assert_not_called() + mocks["set_claude_auth_notified"].assert_not_called() + mocks["get_github_client"].assert_not_called() + + def test_no_notification_on_usage_limit(self): + with _patched_main(claude_status=ClaudeStatus.USAGE_LIMIT) as mocks: + main() + mocks["send_ntfy_sync"].assert_not_called() + + def test_clears_latch_when_available_again(self): + with _patched_main(claude_status=ClaudeStatus.AVAILABLE, assigned=[]) as mocks: + mocks["get_claude_auth_notified"].return_value = True + main() + mocks["set_claude_auth_notified"].assert_called_once_with(False) + + def test_does_not_clear_latch_when_never_set(self): + with _patched_main(claude_status=ClaudeStatus.AVAILABLE, assigned=[]) as mocks: + main() + mocks["set_claude_auth_notified"].assert_not_called() + + class TestHandleIssue: def test_skips_blocked_issue(self): g = MagicMock() @@ -394,7 +436,8 @@ def test_dispatches_issue_to_handle_issue(self): with patch("clayde.orchestrator.get_settings", return_value=_mock_settings(enabled=True)), \ patch("clayde.orchestrator.setup_logging"), \ patch("clayde.orchestrator.init_tracer"), \ - patch("clayde.orchestrator.is_claude_available", return_value=True), \ + patch("clayde.orchestrator.check_claude_availability", return_value=ClaudeStatus.AVAILABLE), \ + patch("clayde.orchestrator.get_claude_auth_notified", return_value=False), \ patch("clayde.orchestrator.get_github_client"), \ patch("clayde.orchestrator.get_assigned_issues", return_value=[item]), \ patch("clayde.orchestrator.load_state", return_value={"issues": {}}), \ @@ -412,7 +455,8 @@ def test_dispatches_standalone_pr_to_handle_standalone_pr(self): with patch("clayde.orchestrator.get_settings", return_value=_mock_settings(enabled=True)), \ patch("clayde.orchestrator.setup_logging"), \ patch("clayde.orchestrator.init_tracer"), \ - patch("clayde.orchestrator.is_claude_available", return_value=True), \ + patch("clayde.orchestrator.check_claude_availability", return_value=ClaudeStatus.AVAILABLE), \ + patch("clayde.orchestrator.get_claude_auth_notified", return_value=False), \ patch("clayde.orchestrator.get_github_client"), \ patch("clayde.orchestrator.get_assigned_issues", return_value=[item]), \ patch("clayde.orchestrator.load_state", return_value={"issues": {}}), \ @@ -431,7 +475,8 @@ def test_skips_pr_already_tracked_as_issue(self): with patch("clayde.orchestrator.get_settings", return_value=_mock_settings(enabled=True)), \ patch("clayde.orchestrator.setup_logging"), \ patch("clayde.orchestrator.init_tracer"), \ - patch("clayde.orchestrator.is_claude_available", return_value=True), \ + patch("clayde.orchestrator.check_claude_availability", return_value=ClaudeStatus.AVAILABLE), \ + patch("clayde.orchestrator.get_claude_auth_notified", return_value=False), \ patch("clayde.orchestrator.get_github_client"), \ patch("clayde.orchestrator.get_assigned_issues", return_value=[item]), \ patch("clayde.orchestrator.load_state", return_value={"issues": {}}), \ diff --git a/tests/test_state.py b/tests/test_state.py index d91479a..1eff041 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -72,3 +72,34 @@ def test_merges_updates(self, tmp_path): "owner": "o", "pr_url": "pr", } + + +class TestClaudeAuthNotified: + def test_defaults_to_false_when_unset(self, tmp_path): + sf = tmp_path / "state.json" + sf.write_text(json.dumps({"issues": {}})) + with patch.object(state_mod, "_STATE_FILE", sf): + assert state_mod.get_claude_auth_notified() is False + + def test_defaults_to_false_when_file_missing(self, tmp_path): + sf = tmp_path / "nonexistent.json" + with patch.object(state_mod, "_STATE_FILE", sf): + assert state_mod.get_claude_auth_notified() is False + + def test_set_then_get_roundtrip(self, tmp_path): + sf = tmp_path / "state.json" + sf.write_text(json.dumps({"issues": {}})) + with patch.object(state_mod, "_STATE_FILE", sf): + state_mod.set_claude_auth_notified(True) + assert state_mod.get_claude_auth_notified() is True + state_mod.set_claude_auth_notified(False) + assert state_mod.get_claude_auth_notified() is False + + def test_set_preserves_issues(self, tmp_path): + sf = tmp_path / "state.json" + sf.write_text(json.dumps({"issues": {"url1": {"owner": "o"}}})) + with patch.object(state_mod, "_STATE_FILE", sf): + state_mod.set_claude_auth_notified(True) + result = json.loads(sf.read_text()) + assert result["issues"] == {"url1": {"owner": "o"}} + assert result["claude_auth_failure_notified"] is True