From 3a267b775b30e3fc6f09c91fee6f6d35faac969f Mon Sep 17 00:00:00 2001 From: "Claude (noether)" Date: Thu, 23 Jul 2026 06:01:49 +0200 Subject: [PATCH] lurker: fall back to opencode on ANY claude dead-end, not just a rate-limit phrase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bullpen fix round stalled when @testdesigner's claude run returned rc 1 with no usable result. The lurker rendered that as "(@nick: no result, rc 1)" and POSTED it, hanging the round behind a useless message. The opencode swap never fired because it only matched a rate-limit PHRASE in the answer — and a structured API failure (is_error / api_error_status, e.g. a 429) carries no such phrase; the CLI collapses it to "no result". * run_claude now returns (answer, ok). ok is False for an empty/error result, a structured API error (surfaced with its api_error_status so it is diagnosable, not swallowed), a timeout, or a non-zero rc. * run_agent swaps to opencode on ok=False OR the rate-limit phrase. If opencode also dead-ends, it returns the more informative of the two failures. Same failure family as the bullseye 200-on-refused-post and the opencode dropped-final-text: a failure that looks like success/silence. Falling back on any dead-end is strictly better than posting one. The _drive_lurker harness stub was updated to the (answer, ok) contract. Its own new test (test_lurker_swap.py) initially LEAKED — it assigned subprocess.run directly instead of via monkeypatch, so the stub bled into every later test that shelled out (25 spurious test_show_shell_safe failures). Fixed to monkeypatch, which auto-restores. Co-Authored-By: Claude Opus 4.8 --- lurker/bullpen-lurker | 43 ++++++++++++---- tests/test_lurker_swap.py | 94 ++++++++++++++++++++++++++++++++++ tests/test_post_secret_gate.py | 4 +- 3 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 tests/test_lurker_swap.py diff --git a/lurker/bullpen-lurker b/lurker/bullpen-lurker index 47f4876..6b51f60 100755 --- a/lurker/bullpen-lurker +++ b/lurker/bullpen-lurker @@ -186,16 +186,32 @@ def run_opencode(prompt): return _cap(f"(@{NICK} opencode error: {type(e).__name__}: {e})") def run_agent(prompt): - """Per-nick runtime, with automatic swap when Anthropic rate-limits.""" + """Per-nick runtime, with automatic swap when claude can't deliver. + + Swap to opencode on TWO signals, not one: an explicit rate-limit phrase in the answer, OR + any claude FAILURE at all (empty result, is_error, API error, timeout, non-zero rc). The + old code only matched the phrase, so a 429 that arrives as structured JSON (is_error=true, + api_error_status, empty result) — which the CLI renders as "(no result, rc 1)", NOT as a + rate-limit string — never triggered the swap. The round then stalled behind a useless post. + Falling back on ANY dead-end is strictly better than posting one.""" if RUNTIME == "opencode": return run_opencode(prompt) - ans = run_claude(prompt) - if RATE_LIMIT_RE.search(ans or ""): - sys.stderr.write(f"@{NICK}: claude RATE-LIMITED -> swapping to opencode ({_ocmodel()})\n") - return run_opencode(prompt) + ans, ok = run_claude(prompt) + if not ok or RATE_LIMIT_RE.search(ans or ""): + why = "rate-limited" if RATE_LIMIT_RE.search(ans or "") else "failed" + sys.stderr.write(f"@{NICK}: claude {why} ({(ans or '')[:120]!r}) -> opencode ({_ocmodel()})\n") + oc = run_opencode(prompt) + # If opencode ALSO dead-ends, hand back the more informative of the two so the room at + # least sees a real diagnosis instead of two layers of "(no result)". + if oc and not oc.lstrip().startswith(f"(@{NICK}"): + return oc + return ans if (ans and not ok) else oc return ans def run_claude(prompt): + """Returns (answer, ok). ok is False when claude produced nothing usable — an empty/error + result, a structured API error (is_error / api_error_status, e.g. a 429), a timeout, or a + non-zero rc — so the caller can fall back rather than post the failure verbatim.""" cmd = [CLAUDE, "-p", prompt, "--dangerously-skip-permissions", "--output-format", "json", "--max-turns", MAX_TURNS] if MODEL: @@ -203,14 +219,23 @@ def run_claude(prompt): try: p = subprocess.run(cmd, cwd=LURKER_DIR, capture_output=True, text=True, timeout=RUN_TIMEOUT) except subprocess.TimeoutExpired: - return f"(@{NICK} timed out)" + return f"(@{NICK} timed out)", False try: d = json.loads(p.stdout) - return _cap(d.get("result") or d.get("error") or f"(@{NICK}: no result, rc {p.returncode})") + result = d.get("result") or d.get("error") or "" + # A structured API failure (429/5xx) carries no usable result but sets these fields; make + # it visible AND matchable so the rate-limit regex and the ok flag can both act on it. + if d.get("is_error") or d.get("api_error_status"): + api = d.get("api_error_status") or d.get("subtype") or "error" + return _cap(f"(@{NICK}: claude API error [{api}] {result}".strip() + ")"), False + if not result.strip(): + return _cap(f"(@{NICK}: no result, rc {p.returncode})"), False + return _cap(result), True except Exception: if p.returncode != 0: - return _cap(f"(@{NICK} error rc{p.returncode}: {(p.stderr or p.stdout)[:200].strip()})") - return _cap((p.stdout or f"(@{NICK}: unparsable output)").strip()) + return _cap(f"(@{NICK} error rc{p.returncode}: {(p.stderr or p.stdout)[:200].strip()})"), False + txt = (p.stdout or "").strip() + return (_cap(txt), True) if txt else (_cap(f"(@{NICK}: unparsable output)"), False) def build_prompt(m): asker = m.get("from", "someone") diff --git a/tests/test_lurker_swap.py b/tests/test_lurker_swap.py new file mode 100644 index 0000000..8b474df --- /dev/null +++ b/tests/test_lurker_swap.py @@ -0,0 +1,94 @@ +"""Spec: a claude dead-end must fall back to opencode, not stall the room. + +The bullpen fix round stalled on 2026-07-23 when @testdesigner's claude run returned rc 1 with +no usable result. The lurker rendered that as the string "(@nick: no result, rc 1)" and posted +it — the round hung behind a useless message. The rate-limit swap didn't fire because it only +matched a rate-limit PHRASE, and a structured API failure (is_error / api_error_status, e.g. a +429) carries no such phrase. run_claude now reports (answer, ok); run_agent swaps on ok=False. +""" + +import importlib.util +import os +import sys +from importlib.machinery import SourceFileLoader + +import pytest + +LURKER = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "lurker", "bullpen-lurker") + + +def _load(): + loader = SourceFileLoader("lurker_swap_under_test", LURKER) + spec = importlib.util.spec_from_loader(loader.name, loader) + mod = importlib.util.module_from_spec(spec) + sys.modules[loader.name] = mod + loader.exec_module(mod) + return mod + + +@pytest.fixture +def lurker(): + m = _load() + m.RUNTIME = "claude" # exercise the swap path, not the opencode-native path + return m + + +def _fake_claude(mod, monkeypatch, out): + """Drive run_claude with a canned subprocess result (json stdout + rc). + + Uses monkeypatch so subprocess.run is RESTORED after the test — an earlier version assigned + mod.subprocess.run directly, which mutated the shared subprocess module globally and made + every later test that shelled out (test_show_shell_safe) run against the stub. Test scratch + that leaks into other tests is exactly the failure this suite is meant to prevent.""" + import types + def fake_run(*a, **k): + return types.SimpleNamespace(stdout=out.get("stdout", ""), + stderr=out.get("stderr", ""), + returncode=out.get("rc", 0)) + monkeypatch.setattr(mod.subprocess, "run", fake_run) + + +def test_run_claude_flags_a_structured_api_error_as_failure(lurker, monkeypatch): + import json + _fake_claude(lurker, monkeypatch, {"stdout": json.dumps( + {"is_error": True, "api_error_status": 429, "result": ""}), "rc": 1}) + ans, ok = lurker.run_claude("x") + assert ok is False + assert "429" in ans, "the API status must be visible, not swallowed into 'no result'" + + +def test_run_claude_flags_empty_result_as_failure(lurker, monkeypatch): + import json + _fake_claude(lurker, monkeypatch, {"stdout": json.dumps({"result": "", "is_error": False}), "rc": 1}) + ans, ok = lurker.run_claude("x") + assert ok is False + + +def test_run_claude_reports_a_real_answer_as_success(lurker, monkeypatch): + import json + _fake_claude(lurker, monkeypatch, {"stdout": json.dumps({"result": "the answer", "is_error": False}), "rc": 0}) + ans, ok = lurker.run_claude("x") + assert ok is True and "the answer" in ans + + +def test_run_agent_swaps_to_opencode_on_any_claude_failure(lurker, monkeypatch): + monkeypatch.setattr(lurker, "run_claude", lambda p: ("(@x: no result, rc 1)", False)) + monkeypatch.setattr(lurker, "run_opencode", lambda p: "opencode delivered") + assert lurker.run_agent("do the thing") == "opencode delivered", ( + "a claude dead-end with no rate-limit phrase must still fall back to opencode") + + +def test_run_agent_does_not_swap_when_claude_succeeds(lurker, monkeypatch): + monkeypatch.setattr(lurker, "run_claude", lambda p: ("claude answer", True)) + monkeypatch.setattr(lurker, "run_opencode", + lambda p: pytest.fail("must not call opencode when claude succeeded")) + assert lurker.run_agent("x") == "claude answer" + + +def test_run_agent_keeps_claude_diagnosis_when_opencode_also_dead_ends(lurker, monkeypatch): + monkeypatch.setattr(lurker, "NICK", "x") + monkeypatch.setattr(lurker, "run_claude", lambda p: ("(@x: claude API error [429] )", False)) + monkeypatch.setattr(lurker, "run_opencode", lambda p: "(@x opencode: no output rc1)") + out = lurker.run_agent("x") + assert "429" in out, "when both fail, surface the more informative failure, not empty noise" diff --git a/tests/test_post_secret_gate.py b/tests/test_post_secret_gate.py index 2b48006..c8b2d2d 100644 --- a/tests/test_post_secret_gate.py +++ b/tests/test_post_secret_gate.py @@ -449,7 +449,9 @@ def _drive_lurker(tmp_path, monkeypatch, messages, name): raise _Exit() mod.room_read = fake_room_read - mod.run_claude = lambda prompt: (calls["claude"].append(prompt), "an answer")[1] + # run_claude returns (answer, ok) since the rate-limit/failure-swap change; the stub must + # honour that contract or run_agent's `ans, ok = run_claude(...)` unpack fails. + mod.run_claude = lambda prompt: (calls["claude"].append(prompt), ("an answer", True))[1] mod.say = lambda body, to="", typ="chat", in_reply_to=None: ( calls["said"].append((typ, to, body)), True)[1] monkeypatch.setattr(mod.time, "sleep", lambda s: None)