lurker: fall back to opencode on ANY claude dead-end, not just a rate-limit phrase

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 <noreply@anthropic.com>
This commit is contained in:
Claude (noether)
2026-07-23 06:01:49 +02:00
parent c17ff3f7fa
commit 3a267b775b
3 changed files with 131 additions and 10 deletions
+34 -9
View File
@@ -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")
+94
View File
@@ -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"
+3 -1
View File
@@ -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)